From ed4a16d38fa009f949109f600399cf68a597c201 Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Fri, 27 Feb 2026 15:11:19 +0000 Subject: [PATCH] feat: add JSON datasource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add JSON datasource (io/json.py) alongside SAC datasource (io/sac.py) - Rename cli/ → _cli/ and aimbat_types/ → _types/ for consistency - Move data I/O logic to io/_data.py - Reorganise tests to mirror new src layout - Add core-concepts documentation page --- .github/copilot-instructions.md | 11 +- docs/api/aimbat/{cli.md => _cli.md} | 4 +- .../api/aimbat/{aimbat_types.md => _types.md} | 4 +- docs/first-steps/core-concepts.md | 43 ++ docs/first-steps/data.md | 6 +- docs/first-steps/installation.md | 2 +- flake.lock | 6 +- src/aimbat/{cli => _cli}/__init__.py | 0 src/aimbat/{cli/_align.py => _cli/align.py} | 22 +- src/aimbat/{cli/_common.py => _cli/common.py} | 66 +- src/aimbat/_cli/data.py | 151 ++++ src/aimbat/{cli/_event.py => _cli/event.py} | 80 +- src/aimbat/{cli/_pick.py => _cli/pick.py} | 28 +- src/aimbat/{cli/_plot.py => _cli/plot.py} | 14 +- .../{cli/_project.py => _cli/project.py} | 10 +- .../_seismogram.py => _cli/seismogram.py} | 67 +- .../{cli/_snapshot.py => _cli/snapshot.py} | 68 +- .../{cli/_station.py => _cli/station.py} | 36 +- .../{cli/_utils => _cli/utils}/__init__.py | 0 src/aimbat/{cli/_utils => _cli/utils}/app.py | 0 .../{cli/_utils => _cli/utils}/sampledata.py | 2 +- src/aimbat/_config.py | 2 +- .../{aimbat_types => _types}/__init__.py | 1 - src/aimbat/{aimbat_types => _types}/_event.py | 0 .../{aimbat_types => _types}/_pydantic.py | 0 .../{aimbat_types => _types}/_seismogram.py | 0 .../{aimbat_types => _types}/_sqlalchemy.py | 0 src/aimbat/aimbat_types/_data.py | 11 - src/aimbat/app.py | 28 +- src/aimbat/cli/_data.py | 96 --- src/aimbat/core/__init__.py | 16 +- src/aimbat/core/_active_event.py | 2 +- src/aimbat/core/_data.py | 119 ++- src/aimbat/core/_event.py | 4 +- src/aimbat/core/_seismogram.py | 2 +- src/aimbat/io/__init__.py | 18 +- src/aimbat/io/_base.py | 2 +- src/aimbat/io/_data.py | 18 + src/aimbat/io/json.py | 91 +++ src/aimbat/io/{_sac.py => sac.py} | 12 +- src/aimbat/models/_models.py | 5 +- src/aimbat/models/_parameters.py | 2 +- src/aimbat/utils/__init__.py | 11 +- tests/conftest.py | 2 +- tests/integration/core/test_data.py | 708 ++++++++++++++++++ tests/integration/{ => core}/test_event.py | 155 +++- tests/integration/{ => core}/test_project.py | 0 .../integration/{ => core}/test_seismogram.py | 2 +- .../integration/{ => core}/test_snapshots.py | 0 tests/integration/{ => core}/test_station.py | 0 .../{ => io}/test_datasource_sac.py | 2 +- tests/integration/{ => models}/test_models.py | 2 +- .../test_operations.py} | 0 tests/integration/test_active_event.py | 145 ---- tests/integration/test_data_io.py | 327 -------- tests/integration/{ => utils}/test_uuid.py | 0 tests/unit/{cli => _cli}/test_common.py | 4 +- .../{aimbat_types => _types}/test_pydantic.py | 4 +- .../test_sqlalchemy.py | 2 +- tests/unit/io/test_json_sources.py | 168 +++++ tests/unit/io/test_sac.py | 2 +- zensical.toml | 7 +- 62 files changed, 1737 insertions(+), 853 deletions(-) rename docs/api/aimbat/{cli.md => _cli.md} (79%) rename docs/api/aimbat/{aimbat_types.md => _types.md} (74%) create mode 100644 docs/first-steps/core-concepts.md rename src/aimbat/{cli => _cli}/__init__.py (100%) rename src/aimbat/{cli/_align.py => _cli/align.py} (67%) rename src/aimbat/{cli/_common.py => _cli/common.py} (51%) create mode 100644 src/aimbat/_cli/data.py rename src/aimbat/{cli/_event.py => _cli/event.py} (73%) rename src/aimbat/{cli/_pick.py => _cli/pick.py} (67%) rename src/aimbat/{cli/_plot.py => _cli/plot.py} (84%) rename src/aimbat/{cli/_project.py => _cli/project.py} (83%) rename src/aimbat/{cli/_seismogram.py => _cli/seismogram.py} (72%) rename src/aimbat/{cli/_snapshot.py => _cli/snapshot.py} (64%) rename src/aimbat/{cli/_station.py => _cli/station.py} (71%) rename src/aimbat/{cli/_utils => _cli/utils}/__init__.py (100%) rename src/aimbat/{cli/_utils => _cli/utils}/app.py (100%) rename src/aimbat/{cli/_utils => _cli/utils}/sampledata.py (95%) rename src/aimbat/{aimbat_types => _types}/__init__.py (94%) rename src/aimbat/{aimbat_types => _types}/_event.py (100%) rename src/aimbat/{aimbat_types => _types}/_pydantic.py (100%) rename src/aimbat/{aimbat_types => _types}/_seismogram.py (100%) rename src/aimbat/{aimbat_types => _types}/_sqlalchemy.py (100%) delete mode 100644 src/aimbat/aimbat_types/_data.py delete mode 100644 src/aimbat/cli/_data.py create mode 100644 src/aimbat/io/_data.py create mode 100644 src/aimbat/io/json.py rename src/aimbat/io/{_sac.py => sac.py} (91%) create mode 100644 tests/integration/core/test_data.py rename tests/integration/{ => core}/test_event.py (67%) rename tests/integration/{ => core}/test_project.py (100%) rename tests/integration/{ => core}/test_seismogram.py (99%) rename tests/integration/{ => core}/test_snapshots.py (100%) rename tests/integration/{ => core}/test_station.py (100%) rename tests/integration/{ => io}/test_datasource_sac.py (99%) rename tests/integration/{ => models}/test_models.py (99%) rename tests/integration/{test_db_operations.py => models/test_operations.py} (100%) delete mode 100644 tests/integration/test_active_event.py delete mode 100644 tests/integration/test_data_io.py rename tests/integration/{ => utils}/test_uuid.py (100%) rename tests/unit/{cli => _cli}/test_common.py (98%) rename tests/unit/{aimbat_types => _types}/test_pydantic.py (97%) rename tests/unit/{aimbat_types => _types}/test_sqlalchemy.py (98%) create mode 100644 tests/unit/io/test_json_sources.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 808817a1..bc6ab2ab 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,16 +38,15 @@ AIMBAT is a seismological tool for automated and interactive measurement of body ``` src/aimbat/ ├── app.py # Cyclopts CLI root — registers all subcommands -├── cli/ # CLI command definitions (thin layer, delegates to core/) +├── _cli/ # CLI command definitions (thin layer, delegates to core/) ├── core/ # Business logic: ICCS/MCCC algorithms, event/seismogram ops │ ├── _active_event.py # Manages the single active event constraint │ ├── _data.py # SAC ingestion entry point │ ├── _iccs.py # ICCS alignment (wraps pysmo.tools.iccs) │ └── _snapshot.py # Parameter state capture for rollback/comparison ├── models/ # SQLModel ORM definitions (Events, Seismograms, Stations, etc.) -│ └── _sqlalchemy.py # SAPandasTimestamp / SAPandasTimedelta type decorators -├── aimbat_types/ # Custom Pydantic types (PydanticTimestamp, enums for parameters) -├── io/ # File I/O — _base.py defines abstract base; _sac.py implements SAC via pysmo +├── _types/ # Custom Pydantic types (PydanticTimestamp, enums for parameters) +├── io/ # File I/O — _base.py defines abstract base; sac.py implements SAC via pysmo ├── utils/ # Shared helpers (JSON→table, UUID truncation, styling, sample data) ├── _config.py # Global Settings (pydantic-settings, env prefix AIMBAT_) ├── _lib/ # Internal mixins (EventParametersValidatorMixin) @@ -89,11 +88,11 @@ Settings live in `_config.py` as a `pydantic-settings` class. All settings can b ### CLI Pattern -Each CLI module in `cli/` creates a Cyclopts `App` instance and registers it with the root app in `app.py`. CLI functions are thin wrappers that open a `Session` from `aimbat.db.engine` and delegate to `core/` functions. +Each CLI module in `_cli/` creates a Cyclopts `App` instance and registers it with the root app in `app.py`. CLI functions are thin wrappers that open a `Session` from `aimbat.db.engine` and delegate to `core/` functions. ### Custom Types -- Use `PydanticTimestamp` / `PydanticTimedelta` (from `aimbat.aimbat_types`) for pandas-compatible time fields in models +- Use `PydanticTimestamp` / `PydanticTimedelta` (from `aimbat._types`) for pandas-compatible time fields in models - Use `PydanticNegativeTimedelta` / `PydanticPositiveTimedelta` for constrained sign validation - Use `SAPandasTimestamp` / `SAPandasTimedelta` (from `aimbat.models._sqlalchemy`) as the `sa_type` in SQLModel fields diff --git a/docs/api/aimbat/cli.md b/docs/api/aimbat/_cli.md similarity index 79% rename from docs/api/aimbat/cli.md rename to docs/api/aimbat/_cli.md index d698c24b..6d6244ec 100644 --- a/docs/api/aimbat/cli.md +++ b/docs/api/aimbat/_cli.md @@ -1,7 +1,7 @@ -::: aimbat.cli +::: aimbat._cli options: heading_level: 1 - toc_label: aimbat.cli + toc_label: aimbat._cli show_root_heading: true show_root_toc_entry: true inherited_members: true diff --git a/docs/api/aimbat/aimbat_types.md b/docs/api/aimbat/_types.md similarity index 74% rename from docs/api/aimbat/aimbat_types.md rename to docs/api/aimbat/_types.md index 04d0c292..78fb6631 100644 --- a/docs/api/aimbat/aimbat_types.md +++ b/docs/api/aimbat/_types.md @@ -1,7 +1,7 @@ -::: aimbat.aimbat_types +::: aimbat._types options: heading_level: 1 - toc_label: aimbat.aimbat_types + toc_label: aimbat._types show_root_heading: true show_root_toc_entry: true inherited_members: true diff --git a/docs/first-steps/core-concepts.md b/docs/first-steps/core-concepts.md new file mode 100644 index 00000000..0276c5c5 --- /dev/null +++ b/docs/first-steps/core-concepts.md @@ -0,0 +1,43 @@ +# 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. diff --git a/docs/first-steps/data.md b/docs/first-steps/data.md index 08e3b708..472a0701 100644 --- a/docs/first-steps/data.md +++ b/docs/first-steps/data.md @@ -53,7 +53,7 @@ things to be mindful of: 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 - [perks](#snapshots) when setting things up this way! + [benefits](#snapshots) when setting things up this way! [^1]: Deleting items from a project simply drops them from the project. AIMBAT @@ -93,7 +93,7 @@ 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 found by running `#!bash aimbat settings` in your terminal. As some settings are relevant before -a project is created, they cannot stored in the project file. To override these +a project is created, they cannot be stored in the project file. To override these settings you can set the corresponding environment variable directly (e.g. `export AIMBAT_PROJECT=different_project_name.db`) or place those settings in a `.env`[^2] file. Note that if you set them in both places the environment @@ -124,7 +124,7 @@ 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 +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. diff --git a/docs/first-steps/installation.md b/docs/first-steps/installation.md index f5b678da..aa0574ab 100644 --- a/docs/first-steps/installation.md +++ b/docs/first-steps/installation.md @@ -12,7 +12,7 @@ installed using the [`pip`](https://pip.pypa.io/en/stable/) module. However, as AIMBAT is a standalone application (rather than a library), we recommend installing it using [`uv`](https://docs.astral.sh/uv/) instead. `uv` is a single binary that doesn't require any dependencies to be installed, and it -allows to install and run AIMBAT in an isolated environment. +allows you to install and run AIMBAT in an isolated environment. ## Running AIMBAT without installing diff --git a/flake.lock b/flake.lock index 5e1bf835..235e43a7 100644 --- a/flake.lock +++ b/flake.lock @@ -54,11 +54,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", + "lastModified": 1771848320, + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", "owner": "nixos", "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", "type": "github" }, "original": { diff --git a/src/aimbat/cli/__init__.py b/src/aimbat/_cli/__init__.py similarity index 100% rename from src/aimbat/cli/__init__.py rename to src/aimbat/_cli/__init__.py diff --git a/src/aimbat/cli/_align.py b/src/aimbat/_cli/align.py similarity index 67% rename from src/aimbat/cli/_align.py rename to src/aimbat/_cli/align.py index c048d5cb..4f16dbb7 100644 --- a/src/aimbat/cli/_align.py +++ b/src/aimbat/_cli/align.py @@ -5,7 +5,7 @@ starting point instead, with the resulting pick stored in `t1`. """ -from ._common import GlobalParameters, simple_exception +from .common import GlobalParameters, simple_exception from cyclopts import App, Parameter from typing import Annotated @@ -20,11 +20,17 @@ def cli_iccs_run( autoselect: bool = False, global_parameters: GlobalParameters | None = None, ) -> None: - """Run the ICCS algorithm. + """Run the ICCS algorithm to align seismograms for the active 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 + starting point. Args: - autoflip: Whether to automatically flip seismograms (multiply data by -1). - autoselect: Whether to automatically de-select seismograms. + autoflip: Whether to automatically flip seismograms (multiply data by -1) + when the cross-correlation is negative. + autoselect: Whether to automatically de-select seismograms whose + cross-correlation with the stack falls below `min_ccnorm`. """ from aimbat.db import engine from aimbat.core import create_iccs_instance, run_iccs @@ -44,10 +50,14 @@ def cli_mccc_run( all_seismograms: Annotated[bool, Parameter(name="all")] = False, global_parameters: GlobalParameters | None = None, ) -> None: - """Run the MCCC algorithm. + """Run the MCCC algorithm to refine arrival time picks for the active event. + + Multi-channel cross-correlation simultaneously determines the optimal time + shifts for all seismograms. Results are stored in `t1`. Args: - all_seismograms: Whether to include all seismograms in the MCCC processing, or just the selected ones. + all_seismograms: Include all seismograms in MCCC processing, not just + the currently selected ones. """ from aimbat.db import engine from aimbat.core import create_iccs_instance, run_mccc diff --git a/src/aimbat/cli/_common.py b/src/aimbat/_cli/common.py similarity index 51% rename from src/aimbat/cli/_common.py rename to src/aimbat/_cli/common.py index da779427..3d595820 100644 --- a/src/aimbat/cli/_common.py +++ b/src/aimbat/_cli/common.py @@ -2,12 +2,13 @@ from aimbat import settings from dataclasses import dataclass -from cyclopts import Parameter +from cyclopts import Parameter, Token from typing import Callable, Any +import uuid -# -------------------------------------------------- +# ----------------------------------------------------------------------- # Common parameters -# -------------------------------------------------- +# ----------------------------------------------------------------------- @Parameter(name="*") @@ -44,6 +45,65 @@ class TableParameters: "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.""" + + def converter(hint: type, tokens: tuple[Token, ...]) -> uuid.UUID: + (token,) = tokens + value = token.value + try: + return uuid.UUID(value) + except ValueError: + from aimbat.db import engine + from aimbat.utils import string_to_uuid + from sqlmodel import Session + + with Session(engine) as session: + return string_to_uuid(session, value, model_class) + + return converter + + +def id_parameter(model_class: type) -> Parameter: + """Create a Parameter for a record ID with automatic UUID prefix resolution.""" + return Parameter( + name="id", + help="Full UUID or any unique prefix as shown in the table.", + converter=_make_uuid_converter(model_class), + ) + + +def use_station_parameter(model_class: type) -> Parameter: + """Create a Parameter for --use-station with automatic UUID prefix resolution.""" + return Parameter( + name="use-station", + help="UUID (or unique prefix) of an existing station to link to instead of" + " extracting one from each data source.", + converter=_make_uuid_converter(model_class), + ) + + +def use_event_parameter(model_class: type) -> Parameter: + """Create a Parameter for --use-event with automatic UUID prefix resolution.""" + return Parameter( + name="use-event", + help="UUID (or unique prefix) of an existing event to link to instead of" + " extracting one from each data source.", + converter=_make_uuid_converter(model_class), + ) + + # ------------------------------------------------ # Hints for error messages # ------------------------------------------------ diff --git a/src/aimbat/_cli/data.py b/src/aimbat/_cli/data.py new file mode 100644 index 00000000..073b8896 --- /dev/null +++ b/src/aimbat/_cli/data.py @@ -0,0 +1,151 @@ +"""Manage data sources in an AIMBAT project. + +A *data source* is a file that AIMBAT reads seismogram waveforms and metadata +from. When a data source is added, AIMBAT extracts and stores the associated +station, event, and seismogram records in the project database — provided the +data type supports it. + +**Supported data types** (`--type`): + +- `sac` *(default)*: SAC waveform file. Extracts station, event, and seismogram + data automatically. +- `json_station`: JSON file containing station metadata only. No seismogram is + created. +- `json_event`: JSON file containing event metadata only. No seismogram is + created. + +**Typical workflow:** + +``` +aimbat project create +aimbat data add *.sac +aimbat event list # find the event ID +aimbat event activate +``` + +Re-adding a data source that is already in the project is safe — existing +records are reused rather than duplicated. +""" + +from .common import ( + GlobalParameters, + TableParameters, + simple_exception, + ALL_EVENTS_PARAMETER, + use_station_parameter, + use_event_parameter, +) +from aimbat.models import AimbatEvent, AimbatStation +from aimbat.io import DataType +from sqlmodel import Session +from cyclopts import App, Parameter, validators +from pathlib import Path +from typing import Annotated +import uuid + +app = App(name="data", help=__doc__, help_format="markdown") + + +@app.command(name="add") +@simple_exception +def cli_data_add( + data_sources: Annotated[ + list[Path], + Parameter( + name="sources", + consume_multiple=True, + validator=validators.Path(exists=True), + ), + ], + *, + data_type: Annotated[DataType, Parameter(name="type")] = DataType.SAC, + station_id: Annotated[ + uuid.UUID | None, use_station_parameter(AimbatStation) + ] = None, + event_id: Annotated[uuid.UUID | None, use_event_parameter(AimbatEvent)] = None, + dry_run: Annotated[bool, Parameter(name="dry-run")] = False, + show_progress_bar: Annotated[bool, Parameter(name="progress")] = True, + global_parameters: GlobalParameters | None = None, +) -> None: + """Add or update data sources in the AIMBAT project. + + Each data source is processed according to `--type`. For `sac` (the + default), AIMBAT extracts station, event, and seismogram metadata directly + from the file. For types that cannot extract a station or event (e.g. a + format that only carries waveform data), supply `--use-station` and/or + `--use-event` to link to records that already exist in the project. + + Station and event deduplication is automatic: if a matching record already + exists it is reused. Re-running `data add` on the same files is safe. + + Use `--dry-run` to preview what would be added without touching the + database. + + Args: + data_sources: One or more data source paths to add. + data_type: Format of the data sources. Determines which metadata + (station, event, seismogram) can be extracted automatically. + dry_run: Preview which records would be added without modifying the + database. + show_progress_bar: Display a progress bar while ingesting sources. + """ + from aimbat.db import engine + from aimbat.core import add_data_to_project + + global_parameters = global_parameters or GlobalParameters() + + disable_progress_bar = not show_progress_bar + + with Session(engine) as session: + add_data_to_project( + session, + data_sources, + data_type, + station_id=station_id, + event_id=event_id, + dry_run=dry_run, + disable_progress_bar=disable_progress_bar, + ) + + +@app.command(name="list") +@simple_exception +def cli_data_list( + *, + all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, + table_parameters: TableParameters | None = None, + global_parameters: GlobalParameters | None = None, +) -> None: + """Print a table of data sources registered in the AIMBAT project.""" + from aimbat.db import engine + from aimbat.core import print_data_table + + table_parameters = table_parameters or TableParameters() + global_parameters = global_parameters or GlobalParameters() + + with Session(engine) as session: + print_data_table(session, table_parameters.short, all_events) + + +@app.command(name="dump") +@simple_exception +def cli_data_dump( + *, + global_parameters: GlobalParameters | None = None, +) -> None: + """Dump the contents of the AIMBAT data source table to JSON. + + Output can be piped or redirected for use in external tools or scripts. + """ + from aimbat.db import engine + from aimbat.core import dump_data_table_to_json + from rich import print_json + + global_parameters = global_parameters or GlobalParameters() + + with Session(engine) as session: + print_json(dump_data_table_to_json(session)) + + +if __name__ == "__main__": + app() diff --git a/src/aimbat/cli/_event.py b/src/aimbat/_cli/event.py similarity index 73% rename from src/aimbat/cli/_event.py rename to src/aimbat/_cli/event.py index 15f8ac55..20aeddf3 100644 --- a/src/aimbat/cli/_event.py +++ b/src/aimbat/_cli/event.py @@ -1,26 +1,20 @@ """View and manage events in the AIMBAT project.""" -from ._common import GlobalParameters, TableParameters, simple_exception, HINTS -from aimbat.aimbat_types import EventParameter +from .common import ( + GlobalParameters, + TableParameters, + simple_exception, + id_parameter, + ALL_EVENTS_PARAMETER, +) +from aimbat.models import AimbatEvent +from aimbat._types import EventParameter from typing import Annotated from pandas import Timedelta -from cyclopts import App, Parameter +from cyclopts import App from sqlmodel import Session import uuid - -def _string_to_event_uuid(session: Session, event_id: str) -> uuid.UUID: - from aimbat.models import AimbatEvent - from aimbat.utils import string_to_uuid - - return string_to_uuid( - session, - event_id, - AimbatEvent, - custom_error=f"Unable to find event using id: {event_id}. {HINTS.LIST_EVENTS}", - ) - - app = App(name="event", help=__doc__, help_format="markdown") parameter = App( name="parameter", help="Manage event parameters.", help_format="markdown" @@ -31,46 +25,34 @@ def _string_to_event_uuid(session: Session, event_id: str) -> uuid.UUID: @app.command(name="delete") @simple_exception def cli_event_delete( - event_id: Annotated[uuid.UUID | str, Parameter(name="id")], + event_id: Annotated[uuid.UUID, id_parameter(AimbatEvent)], *, global_parameters: GlobalParameters | None = None, ) -> None: - """Delete existing event. - - Args: - event_id: Event ID. - """ + """Delete existing event.""" from aimbat.db import engine from aimbat.core import delete_event_by_id global_parameters = global_parameters or GlobalParameters() with Session(engine) as session: - if not isinstance(event_id, uuid.UUID): - event_id = _string_to_event_uuid(session, event_id) delete_event_by_id(session, event_id) @app.command(name="activate") @simple_exception def cli_event_activate( - event_id: Annotated[uuid.UUID | str, Parameter(name="id")], + event_id: Annotated[uuid.UUID, id_parameter(AimbatEvent)], *, global_parameters: GlobalParameters | None = None, ) -> None: - """Select the event to be active for Processing. - - Args: - event_id: Event ID number. - """ + """Select the event to be active for processing.""" from aimbat.core import set_active_event_by_id from aimbat.db import engine global_parameters = global_parameters or GlobalParameters() with Session(engine) as session: - if not isinstance(event_id, uuid.UUID): - event_id = _string_to_event_uuid(session, event_id) set_active_event_by_id(session, event_id) @@ -80,7 +62,10 @@ def cli_event_dump( *, global_parameters: GlobalParameters | None = None, ) -> None: - """Dump the contents of the AIMBAT event table to json.""" + """Dump the contents of the AIMBAT event table to JSON. + + Output can be piped or redirected for use in external tools or scripts. + """ from aimbat.db import engine from aimbat.core import dump_event_table_to_json from rich import print_json @@ -98,7 +83,11 @@ def cli_event_list( table_parameters: TableParameters | None = None, global_parameters: GlobalParameters | None = None, ) -> None: - """Print information on the events stored in AIMBAT.""" + """Print a table of events stored in the AIMBAT project. + + The active event is highlighted. Use `event activate` to change which event + is processed by subsequent commands. + """ from aimbat.db import engine from aimbat.core import print_event_table @@ -163,13 +152,7 @@ def cli_event_parameter_set( @parameter.command(name="dump") @simple_exception def cli_event_parameter_dump( - all_events: Annotated[ - bool, - Parameter( - name="all", - help="Dump parameters for all events instead of just the active event.", - ), - ] = False, + all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, global_parameters: GlobalParameters | None = None, ) -> None: """Dump event parameter table to json.""" @@ -189,17 +172,16 @@ def cli_event_parameter_dump( @parameter.command(name="list") @simple_exception def cli_event_parameter_list( - all_events: Annotated[ - bool, - Parameter( - name="all", - help="List parameter values for all events instead of just the active event.", - ), - ] = False, + all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, global_parameters: GlobalParameters | None = None, table_parameters: TableParameters | None = None, ) -> None: - """List parameter values for the active event.""" + """List processing parameter values for the active 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 print_event_parameter_table from sqlmodel import Session diff --git a/src/aimbat/cli/_pick.py b/src/aimbat/_cli/pick.py similarity index 67% rename from src/aimbat/cli/_pick.py rename to src/aimbat/_cli/pick.py index ec4f1a84..32193f70 100644 --- a/src/aimbat/cli/_pick.py +++ b/src/aimbat/_cli/pick.py @@ -1,7 +1,12 @@ -"""Interactively update parameters controlling the ICCS algorithm.""" +"""Interactively pick phase arrival times and processing parameters. + +These commands open an interactive matplotlib plot for the active 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. +""" from typing import Annotated -from ._common import GlobalParameters, IccsPlotParameters, simple_exception +from .common import GlobalParameters, IccsPlotParameters, simple_exception from cyclopts import App, Parameter app = App(name="pick", help=__doc__, help_format="markdown") @@ -15,7 +20,11 @@ def cli_update_phase_pick( use_seismogram_image: Annotated[bool, Parameter(name="img")] = False, global_parameters: GlobalParameters | None = None, ) -> None: - """Pick a new phase arrival time. + """Interactively pick a new phase arrival time (t1) for the active 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 + selected seismogram. Args: use_seismogram_image: Use the seismogram image to update pick. @@ -46,7 +55,11 @@ def cli_pick_timewindow( use_seismogram_image: Annotated[bool, Parameter(name="img")] = False, global_parameters: GlobalParameters | None = None, ) -> None: - """Pick a new time window. + """Interactively pick a new cross-correlation time window for the active 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 + seismogram is used during ICCS alignment. Args: use_seismogram_image: Use the seismogram image to pick the time window. @@ -76,7 +89,12 @@ def cli_pick_min_ccnorm( iccs_parameters: IccsPlotParameters | None = None, global_parameters: GlobalParameters | None = None, ) -> None: - """Pick a new minimum cross-correlation norm for auto-selection.""" + """Interactively pick a new minimum cross-correlation norm for auto-selection. + + Opens an interactive plot; click to set the ccnorm threshold. Seismograms + whose cross-correlation with the stack falls below this value will be + automatically de-selected when running ICCS with `--autoselect`. + """ from aimbat.db import engine from aimbat.core import create_iccs_instance, update_min_ccnorm from sqlmodel import Session diff --git a/src/aimbat/cli/_plot.py b/src/aimbat/_cli/plot.py similarity index 84% rename from src/aimbat/cli/_plot.py rename to src/aimbat/_cli/plot.py index 53470ac6..adfc9d35 100644 --- a/src/aimbat/cli/_plot.py +++ b/src/aimbat/_cli/plot.py @@ -1,6 +1,16 @@ -"""Create various plots related to ICCS.""" +"""Create plots for seismograms and ICCS results. -from ._common import ( +Available plots: + +- **data**: raw seismograms sorted by epicentral distance +- **stack**: the ICCS cross-correlation stack for the active event +- **image**: seismograms displayed as a 2-D image (wiggle plot) + +Most plot commands support `--context` / `--no-context` to toggle extra +waveform context, and `--all` to include de-selected seismograms. +""" + +from .common import ( GlobalParameters, IccsPlotParameters, PlotParameters, diff --git a/src/aimbat/cli/_project.py b/src/aimbat/_cli/project.py similarity index 83% rename from src/aimbat/cli/_project.py rename to src/aimbat/_cli/project.py index 88767630..3a76fd4e 100644 --- a/src/aimbat/cli/_project.py +++ b/src/aimbat/_cli/project.py @@ -9,7 +9,7 @@ executed with a database url directly. """ -from ._common import GlobalParameters, simple_exception +from .common import GlobalParameters, simple_exception from cyclopts import App app = App(name="project", help=__doc__, help_format="markdown") @@ -18,7 +18,11 @@ @app.command(name="create") @simple_exception def cli_project_create(*, global_parameters: GlobalParameters | None = None) -> None: - """Create new AIMBAT project.""" + """Create a new AIMBAT project in the current directory. + + Initialises a new project database (`aimbat.db` by default). Run this + once before adding data with `aimbat data add`. + """ from aimbat.db import engine from aimbat.core import create_project @@ -42,7 +46,7 @@ def cli_project_delete(*, global_parameters: GlobalParameters | None = None) -> @app.command(name="info") @simple_exception def cli_project_info(*, global_parameters: GlobalParameters | None = None) -> None: - """Show information on an exisiting project.""" + """Show information on an existing project.""" from aimbat.db import engine from aimbat.core import print_project_info diff --git a/src/aimbat/cli/_seismogram.py b/src/aimbat/_cli/seismogram.py similarity index 72% rename from src/aimbat/cli/_seismogram.py rename to src/aimbat/_cli/seismogram.py index ff3db47c..cbf7a3b4 100644 --- a/src/aimbat/cli/_seismogram.py +++ b/src/aimbat/_cli/seismogram.py @@ -1,9 +1,16 @@ """View and manage seismograms in the AIMBAT project.""" -from ._common import GlobalParameters, TableParameters, simple_exception -from aimbat.aimbat_types import SeismogramParameter +from .common import ( + GlobalParameters, + TableParameters, + simple_exception, + id_parameter, + ALL_EVENTS_PARAMETER, +) +from aimbat.models import AimbatSeismogram +from aimbat._types import SeismogramParameter from typing import Annotated -from cyclopts import App, Parameter +from cyclopts import App import uuid app = App(name="seismogram", help=__doc__, help_format="markdown") @@ -16,26 +23,18 @@ @app.command(name="delete") @simple_exception def cli_seismogram_delete( - seismogram_id: Annotated[uuid.UUID | str, Parameter(name="id")], + seismogram_id: Annotated[uuid.UUID, id_parameter(AimbatSeismogram)], *, global_parameters: GlobalParameters | None = None, ) -> None: - """Delete existing seismogram. - - Args: - seismogram_id: Seismogram ID. - """ - from aimbat.utils import string_to_uuid + """Delete existing seismogram.""" from aimbat.db import engine - from aimbat.models import AimbatSeismogram from aimbat.core import delete_seismogram_by_id from sqlmodel import Session global_parameters = global_parameters or GlobalParameters() with Session(engine) as session: - if not isinstance(seismogram_id, uuid.UUID): - seismogram_id = string_to_uuid(session, seismogram_id, AimbatSeismogram) delete_seismogram_by_id(session, seismogram_id) @@ -45,7 +44,10 @@ def cli_seismogram_dump( *, global_parameters: GlobalParameters | None = None, ) -> None: - """Dump the contents of the AIMBAT seismogram table to json.""" + """Dump the contents of the AIMBAT seismogram table to JSON. + + Output can be piped or redirected for use in external tools or scripts. + """ from aimbat.db import engine from aimbat.core import dump_seismogram_table_to_json from sqlmodel import Session @@ -61,15 +63,11 @@ def cli_seismogram_dump( @simple_exception def cli_seismogram_list( *, - all_events: Annotated[bool, Parameter("all")] = False, + all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, table_parameters: TableParameters | None = None, global_parameters: GlobalParameters | None = None, ) -> None: - """Print information on the seismograms in the active event. - - Args: - all_events: Select seismograms for all events. - """ + """Print information on the seismograms in the active event.""" from aimbat.db import engine from aimbat.core import print_seismogram_table from sqlmodel import Session @@ -84,7 +82,7 @@ def cli_seismogram_list( @parameter.command(name="get") @simple_exception def cli_seismogram_parameter_get( - seismogram_id: Annotated[uuid.UUID | str, Parameter(name="id")], + seismogram_id: Annotated[uuid.UUID, id_parameter(AimbatSeismogram)], name: SeismogramParameter, *, global_parameters: GlobalParameters | None = None, @@ -92,27 +90,22 @@ def cli_seismogram_parameter_get( """Get the value of a processing parameter. Args: - seismogram_id: Seismogram ID number. name: Name of the seismogram parameter. """ - from aimbat.utils import string_to_uuid from aimbat.db import engine - from aimbat.models import AimbatSeismogram from aimbat.core import get_seismogram_parameter_by_id from sqlmodel import Session global_parameters = global_parameters or GlobalParameters() with Session(engine) as session: - if not isinstance(seismogram_id, uuid.UUID): - seismogram_id = string_to_uuid(session, seismogram_id, AimbatSeismogram) print(get_seismogram_parameter_by_id(session, seismogram_id, name)) @parameter.command(name="set") @simple_exception def cli_seismogram_parameter_set( - seismogram_id: Annotated[uuid.UUID | str, Parameter(name="id")], + seismogram_id: Annotated[uuid.UUID, id_parameter(AimbatSeismogram)], name: SeismogramParameter, value: str, *, @@ -121,34 +114,23 @@ def cli_seismogram_parameter_set( """Set value of a processing parameter. Args: - seismogram_id: Seismogram ID number. name: Name of the seismogram parameter. value: Value of the seismogram parameter. """ - from aimbat.utils import string_to_uuid from aimbat.db import engine - from aimbat.models import AimbatSeismogram from aimbat.core import set_seismogram_parameter_by_id from sqlmodel import Session global_parameters = global_parameters or GlobalParameters() with Session(engine) as session: - if not isinstance(seismogram_id, uuid.UUID): - seismogram_id = string_to_uuid(session, seismogram_id, AimbatSeismogram) set_seismogram_parameter_by_id(session, seismogram_id, name, value) @parameter.command(name="dump") @simple_exception def cli_seismogram_parameter_dump( - all_events: Annotated[ - bool, - Parameter( - name="all", - help="Dump parameters for all events instead of just the active event.", - ), - ] = False, + all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, global_parameters: GlobalParameters | None = None, ) -> None: """Dump seismogram parameter table to json.""" @@ -171,7 +153,12 @@ def cli_seismogram_parameter_list( global_parameters: GlobalParameters | None = None, table_parameters: TableParameters | None = None, ) -> None: - """List parameter values for the active event.""" + """List processing parameter values for seismograms in the active 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 print_seismogram_parameter_table from sqlmodel import Session diff --git a/src/aimbat/cli/_snapshot.py b/src/aimbat/_cli/snapshot.py similarity index 64% rename from src/aimbat/cli/_snapshot.py rename to src/aimbat/_cli/snapshot.py index 1001bf2b..86dffc41 100644 --- a/src/aimbat/cli/_snapshot.py +++ b/src/aimbat/_cli/snapshot.py @@ -1,8 +1,21 @@ -"""View and manage snapshots.""" - -from ._common import GlobalParameters, TableParameters, simple_exception +"""View and manage snapshots of processing parameters. + +A snapshot captures the current event and seismogram processing parameters +(e.g. time window, bandpass filter, picks) so they can be restored later. +Use `snapshot create` before making experimental changes, and `snapshot rollback` +to undo them if needed. +""" + +from .common import ( + GlobalParameters, + TableParameters, + simple_exception, + id_parameter, + ALL_EVENTS_PARAMETER, +) +from aimbat.models import AimbatSnapshot from typing import Annotated -from cyclopts import App, Parameter +from cyclopts import App import uuid app = App(name="snapshot", help=__doc__, help_format="markdown") @@ -13,10 +26,13 @@ def cli_snapshot_create( comment: str | None = None, *, global_parameters: GlobalParameters | None = None ) -> None: - """Create new snapshot. + """Create a new snapshot of current processing parameters. + + Saves the current event and seismogram parameters for the active event so + they can be restored later with `snapshot rollback`. Args: - comment: Create snapshot with optional comment. + comment: Optional description to help identify this snapshot later. """ from aimbat.db import engine from aimbat.core import create_snapshot @@ -31,66 +47,46 @@ def cli_snapshot_create( @app.command(name="rollback") @simple_exception def cli_snapshot_rollback( - snapshot_id: Annotated[uuid.UUID | str, Parameter(name="id")], + snapshot_id: Annotated[uuid.UUID, id_parameter(AimbatSnapshot)], *, global_paramaters: GlobalParameters | None = None, ) -> None: - """Rollback to snapshot. - - Args: - snapshot_id: Snapshot ID Number. - """ - from aimbat.utils import string_to_uuid + """Rollback to snapshot.""" from aimbat.db import engine - from aimbat.models import AimbatSnapshot from aimbat.core import rollback_to_snapshot_by_id from sqlmodel import Session global_paramaters = global_paramaters or GlobalParameters() with Session(engine) as session: - if not isinstance(snapshot_id, uuid.UUID): - snapshot_id = string_to_uuid(session, snapshot_id, AimbatSnapshot) rollback_to_snapshot_by_id(session, snapshot_id) @app.command(name="delete") @simple_exception def cli_snapshop_delete( - snapshot_id: Annotated[uuid.UUID | str, Parameter(name="id")], + snapshot_id: Annotated[uuid.UUID, id_parameter(AimbatSnapshot)], *, global_parameters: GlobalParameters | None = None, ) -> None: - """Delete existing snapshot. - - Args: - snapshot_id: Snapshot ID Number. - """ + """Delete existing snapshot.""" from aimbat.db import engine - from aimbat.utils import string_to_uuid - from aimbat.models import AimbatSnapshot from aimbat.core import delete_snapshot_by_id from sqlmodel import Session global_parameters = global_parameters or GlobalParameters() with Session(engine) as session: - if not isinstance(snapshot_id, uuid.UUID): - snapshot_id = string_to_uuid(session, snapshot_id, AimbatSnapshot) delete_snapshot_by_id(session, snapshot_id) @app.command(name="dump") @simple_exception def cli_snapshot_dump( - all_events: Annotated[bool, Parameter("all")] = False, + all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, global_parameters: GlobalParameters | None = None, ) -> None: - """Dump the contents of the AIMBAT snapshot table to json. - - Args: - all_events: Select snapshots for all events. - """ + """Dump the contents of the AIMBAT snapshot table to json.""" from aimbat.db import engine from aimbat.core import dump_snapshot_tables_to_json from sqlmodel import Session @@ -105,15 +101,11 @@ def cli_snapshot_dump( @app.command(name="list") @simple_exception def cli_snapshot_list( - all_events: Annotated[bool, Parameter("all")] = False, + all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, table_parameters: TableParameters | None = None, global_parameters: GlobalParameters | None = None, ) -> None: - """Print information on the snapshots for the active event. - - Args: - all_events: Select snapshots for all events. - """ + """Print information on the snapshots for the active event.""" from aimbat.db import engine from aimbat.core import print_snapshot_table from sqlmodel import Session diff --git a/src/aimbat/cli/_station.py b/src/aimbat/_cli/station.py similarity index 71% rename from src/aimbat/cli/_station.py rename to src/aimbat/_cli/station.py index 532bfa98..0b5fe61a 100644 --- a/src/aimbat/cli/_station.py +++ b/src/aimbat/_cli/station.py @@ -1,8 +1,15 @@ """View and manage stations.""" -from ._common import GlobalParameters, TableParameters, simple_exception +from .common import ( + GlobalParameters, + TableParameters, + simple_exception, + id_parameter, + ALL_EVENTS_PARAMETER, +) +from aimbat.models import AimbatStation from typing import Annotated -from cyclopts import App, Parameter +from cyclopts import App import uuid app = App(name="station", help=__doc__, help_format="markdown") @@ -11,26 +18,18 @@ @app.command(name="delete") @simple_exception def cli_station_delete( - station_id: Annotated[uuid.UUID | str, Parameter(name="id")], + station_id: Annotated[uuid.UUID, id_parameter(AimbatStation)], *, global_parameters: GlobalParameters | None = None, ) -> None: - """Delete existing station. - - Args: - station_id: Station ID. - """ + """Delete existing station.""" from aimbat.db import engine - from aimbat.utils import string_to_uuid from aimbat.core import delete_station_by_id - from aimbat.models import AimbatStation from sqlmodel import Session global_parameters = global_parameters or GlobalParameters() with Session(engine) as session: - if not isinstance(station_id, uuid.UUID): - station_id = string_to_uuid(session, station_id, AimbatStation) delete_station_by_id(session, station_id) @@ -38,15 +37,11 @@ def cli_station_delete( @simple_exception def cli_station_list( *, - all_events: Annotated[bool, Parameter(name="all")] = False, + all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, table_parameters: TableParameters | None = None, global_parameters: GlobalParameters | None = None, ) -> None: - """Print information on the stations used in the active event. - - Args: - all_events: Select stations for all events. - """ + """Print information on the stations used in the active event.""" from aimbat.db import engine from aimbat.core import print_station_table from sqlmodel import Session @@ -64,7 +59,10 @@ def cli_station_dump( *, global_parameters: GlobalParameters | None = None, ) -> None: - """Dump the contents of the AIMBAT station table to json.""" + """Dump the contents of the AIMBAT station table to JSON. + + Output can be piped or redirected for use in external tools or scripts. + """ from aimbat.db import engine from aimbat.core import dump_station_table_to_json diff --git a/src/aimbat/cli/_utils/__init__.py b/src/aimbat/_cli/utils/__init__.py similarity index 100% rename from src/aimbat/cli/_utils/__init__.py rename to src/aimbat/_cli/utils/__init__.py diff --git a/src/aimbat/cli/_utils/app.py b/src/aimbat/_cli/utils/app.py similarity index 100% rename from src/aimbat/cli/_utils/app.py rename to src/aimbat/_cli/utils/app.py diff --git a/src/aimbat/cli/_utils/sampledata.py b/src/aimbat/_cli/utils/sampledata.py similarity index 95% rename from src/aimbat/cli/_utils/sampledata.py rename to src/aimbat/_cli/utils/sampledata.py index 81f90a51..34c7ac18 100644 --- a/src/aimbat/cli/_utils/sampledata.py +++ b/src/aimbat/_cli/utils/sampledata.py @@ -8,7 +8,7 @@ be viewed or changed via `aimbat default sampledata_dir`. """ -from aimbat.cli._common import GlobalParameters, simple_exception +from aimbat._cli.common import GlobalParameters, simple_exception from cyclopts import App app = App(name="sampledata", help=__doc__, help_format="markdown") diff --git a/src/aimbat/_config.py b/src/aimbat/_config.py index 50311bf8..a70baf56 100644 --- a/src/aimbat/_config.py +++ b/src/aimbat/_config.py @@ -1,6 +1,6 @@ """Global configuration options for the AIMBAT application.""" -from aimbat.aimbat_types import PydanticNegativeTimedelta, PydanticPositiveTimedelta +from aimbat._types import PydanticNegativeTimedelta, PydanticPositiveTimedelta from pydantic import Field, model_validator from pydantic_settings import ( BaseSettings, diff --git a/src/aimbat/aimbat_types/__init__.py b/src/aimbat/_types/__init__.py similarity index 94% rename from src/aimbat/aimbat_types/__init__.py rename to src/aimbat/_types/__init__.py index 7ff7ca8c..f8422198 100644 --- a/src/aimbat/aimbat_types/__init__.py +++ b/src/aimbat/_types/__init__.py @@ -5,7 +5,6 @@ _internal_names = set(dir()) -from ._data import * from ._event import * from ._pydantic import * from ._seismogram import * diff --git a/src/aimbat/aimbat_types/_event.py b/src/aimbat/_types/_event.py similarity index 100% rename from src/aimbat/aimbat_types/_event.py rename to src/aimbat/_types/_event.py diff --git a/src/aimbat/aimbat_types/_pydantic.py b/src/aimbat/_types/_pydantic.py similarity index 100% rename from src/aimbat/aimbat_types/_pydantic.py rename to src/aimbat/_types/_pydantic.py diff --git a/src/aimbat/aimbat_types/_seismogram.py b/src/aimbat/_types/_seismogram.py similarity index 100% rename from src/aimbat/aimbat_types/_seismogram.py rename to src/aimbat/_types/_seismogram.py diff --git a/src/aimbat/aimbat_types/_sqlalchemy.py b/src/aimbat/_types/_sqlalchemy.py similarity index 100% rename from src/aimbat/aimbat_types/_sqlalchemy.py rename to src/aimbat/_types/_sqlalchemy.py diff --git a/src/aimbat/aimbat_types/_data.py b/src/aimbat/aimbat_types/_data.py deleted file mode 100644 index 58993678..00000000 --- a/src/aimbat/aimbat_types/_data.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import StrEnum, auto - -__all__ = [ - "DataType", -] - - -class DataType(StrEnum): - """Valid AIMBAT data types.""" - - SAC = auto() diff --git a/src/aimbat/app.py b/src/aimbat/app.py index 5dcc72b1..883f018b 100644 --- a/src/aimbat/app.py +++ b/src/aimbat/app.py @@ -4,6 +4,14 @@ This is the main command line interface for AIMBAT. It must be executed with a command (as specified below) to actually do anything. Help for individual commands is available by typing `aimbat COMMAND --help`. + +## IDs + +Every record in AIMBAT (event, seismogram, station, snapshot, …) has a UUID. +Tables display each UUID truncated to the shortest prefix that is unique within +that table. When a command asks for an ID you may supply any prefix long enough +to identify the record unambiguously — from the shortest displayed prefix up to +the full UUID. Dashes are optional. """ from importlib import metadata @@ -19,16 +27,16 @@ console = Console() app = App(version=__version__, help=__doc__, help_format="markdown", console=console) -app.command("aimbat.cli._align:app", name="align") -app.command("aimbat.cli._data:app", name="data") -app.command("aimbat.cli._event:app", name="event") -app.command("aimbat.cli._pick:app", name="pick") -app.command("aimbat.cli._plot:app", name="plot") -app.command("aimbat.cli._project:app", name="project") -app.command("aimbat.cli._station:app", name="station") -app.command("aimbat.cli._seismogram:app", name="seismogram") -app.command("aimbat.cli._snapshot:app", name="snapshot") -app.command("aimbat.cli._utils:app", name="utils") +app.command("aimbat._cli.align:app", name="align") +app.command("aimbat._cli.data:app", name="data") +app.command("aimbat._cli.event:app", name="event") +app.command("aimbat._cli.pick:app", name="pick") +app.command("aimbat._cli.plot:app", name="plot") +app.command("aimbat._cli.project:app", name="project") +app.command("aimbat._cli.station:app", name="station") +app.command("aimbat._cli.seismogram:app", name="seismogram") +app.command("aimbat._cli.snapshot:app", name="snapshot") +app.command("aimbat._cli.utils:app", name="utils") if __name__ == "__main__": diff --git a/src/aimbat/cli/_data.py b/src/aimbat/cli/_data.py deleted file mode 100644 index 72d6c0fa..00000000 --- a/src/aimbat/cli/_data.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Manage seismogram files in an AIMBAT project.""" - -from ._common import GlobalParameters, TableParameters, simple_exception -from aimbat.aimbat_types import DataType -from sqlmodel import Session -from cyclopts import App, Parameter, validators -from pathlib import Path -from typing import Annotated - -app = App(name="data", help=__doc__, help_format="markdown") - - -@app.command(name="add") -@simple_exception -def cli_data_add( - data_sources: Annotated[ - list[Path], - Parameter( - name="sources", - consume_multiple=True, - validator=validators.Path(exists=True), - ), - ], - *, - data_type: Annotated[DataType, Parameter(name="type")] = DataType.SAC, - dry_run: Annotated[bool, Parameter(name="dry-run")] = False, - show_progress_bar: Annotated[bool, Parameter(name="progress")] = True, - global_parameters: GlobalParameters | None = None, -) -> None: - """Add or update data files in the AIMBAT project. - - Args: - data_sources: Data sources to be added. - data_type: Specify type of seismogram file. - dry_run: If True, print the files that would be added without modifying the database. - show_progress_bar: Display progress bar. - """ - from aimbat.db import engine - from aimbat.core import add_data_to_project - - global_parameters = global_parameters or GlobalParameters() - - disable_progress_bar = not show_progress_bar - - with Session(engine) as session: - add_data_to_project( - session, - data_sources, - data_type, - dry_run, - disable_progress_bar, - ) - - -@app.command(name="list") -@simple_exception -def cli_data_list( - *, - all_events: Annotated[bool, Parameter(name="all")] = False, - table_parameters: TableParameters | None = None, - global_parameters: GlobalParameters | None = None, -) -> None: - """Print information on the data stored in AIMBAT. - - Args: - all_events: Select data for all events. - """ - from aimbat.db import engine - from aimbat.core import print_data_table - - table_parameters = table_parameters or TableParameters() - global_parameters = global_parameters or GlobalParameters() - - with Session(engine) as session: - print_data_table(session, table_parameters.short, all_events) - - -@app.command(name="dump") -@simple_exception -def cli_data_dump( - *, - global_parameters: GlobalParameters | None = None, -) -> None: - """Dump the contents of the AIMBAT data table to json.""" - from aimbat.db import engine - from aimbat.core import dump_data_table_to_json - from rich import print_json - - global_parameters = global_parameters or GlobalParameters() - - with Session(engine) as session: - print_json(dump_data_table_to_json(session)) - - -if __name__ == "__main__": - app() diff --git a/src/aimbat/core/__init__.py b/src/aimbat/core/__init__.py index 9a9abc22..cba0413d 100644 --- a/src/aimbat/core/__init__.py +++ b/src/aimbat/core/__init__.py @@ -1,21 +1,21 @@ # flake8: noqa: E402, F403 -"""Business logic for AIMBAT processing operations. +"""Core logic for AIMBAT. -All functions accept a SQLModel `Session` and operate on the ORM models in -`aimbat.models`. The main areas of functionality are: +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. -- **Data ingestion** — add data sources to the project, linking each to its - station, event, and seismogram records (`add_data_to_project`). +- **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 - and write processing parameters. + and write parameters. - **ICCS / MCCC** — run the Iterative Cross-Correlation and Stack (`run_iccs`) and Multi-Channel Cross-Correlation (`run_mccc`) algorithms; update picks, time windows, and correlation thresholds. -- **Snapshots** — create, restore, and delete parameter snapshots for rollback - and comparison (`create_snapshot`, `rollback_to_snapshot`). +- **Snapshots** — save, restore, and delete parameter snapshots + (`create_snapshot`, `rollback_to_snapshot`). - **Project** — create and delete the project database (`create_project`, `delete_project`). """ diff --git a/src/aimbat/core/_active_event.py b/src/aimbat/core/_active_event.py index f0654278..cf040fc7 100644 --- a/src/aimbat/core/_active_event.py +++ b/src/aimbat/core/_active_event.py @@ -4,7 +4,7 @@ from aimbat.io import clear_seismogram_cache from aimbat.logger import logger from aimbat.models import AimbatEvent -from aimbat.cli._common import HINTS +from aimbat._cli.common import HINTS from sqlmodel import Session, select from sqlalchemy.exc import NoResultFound from contextlib import suppress diff --git a/src/aimbat/core/_data.py b/src/aimbat/core/_data.py index 3ff67a51..238b715b 100644 --- a/src/aimbat/core/_data.py +++ b/src/aimbat/core/_data.py @@ -1,7 +1,8 @@ import os +import uuid from aimbat.core import get_active_event from aimbat.logger import logger -from aimbat.aimbat_types import DataType +from aimbat.io import DataType from aimbat.utils import ( uuid_shortener, make_table, @@ -114,49 +115,68 @@ def _create_seismogram( return aimbat_seismogram -def _add_datasource( - session: Session, datasource: str | os.PathLike, datatype: DataType -) -> AimbatDataSource: - """Add a data source to the AIMBAT database, creating related station, event and seismogram if necessary.""" - missing = [ - label - for supported, label in ( - (supports_station_creation(datatype), "station creation"), - (supports_event_creation(datatype), "event creation"), - (supports_seismogram_creation(datatype), "seismogram creation"), +def _process_datasource( + session: Session, + datasource: str | os.PathLike, + datatype: DataType, + station_id: uuid.UUID | None, + event_id: uuid.UUID | None, +) -> AimbatDataSource | None: + """Process a single data source, creating whichever entities the data type supports. + + Returns an `AimbatDataSource` when seismogram data is created, or `None` + for station-only or event-only imports. + """ + + # Resolve station — use the provided UUID, extract from the source, or skip + if station_id is not None: + aimbat_station: AimbatStation | None = session.get(AimbatStation, station_id) + logger.debug(f"Using station {aimbat_station.name} - {aimbat_station.network} (ID={station_id}).") # type: ignore[union-attr] + elif supports_station_creation(datatype): + aimbat_station = _create_station(session, datasource, datatype) + else: + aimbat_station = None + + # Resolve event — use the provided UUID, extract from the source, or skip + if event_id is not None: + aimbat_event: AimbatEvent | None = session.get(AimbatEvent, event_id) + logger.debug(f"Using event {aimbat_event.time} (ID={event_id}).") # type: ignore[union-attr] + elif supports_event_creation(datatype): + aimbat_event = _create_event(session, datasource, datatype) + else: + aimbat_event = None + + # No seismogram creation → station/event-only import, nothing more to do + if not supports_seismogram_creation(datatype): + return None + + # Seismogram creation requires both a station and an event to link to + if aimbat_station is None: + raise NotImplementedError( + f"{datatype} does not support station creation. " + "Provide a station UUID via --use-station." ) - if not supported - ] - if missing: + if aimbat_event is None: raise NotImplementedError( - f"{datatype} does not support: {', '.join(missing)}. " - "Station and event data must be imported separately before " - "adding seismogram-only data sources." + f"{datatype} does not support event creation. " + "Provide an event UUID via --use-event." ) - aimbat_station = _create_station(session, datasource, datatype) - aimbat_event = _create_event(session, datasource, datatype) aimbat_seismogram = _create_seismogram(session, datasource, datatype) - - # TODO: perhaps adding potentially updated station and event information should be optional? + # TODO: perhaps updating station/event info from the source should be optional aimbat_seismogram.station = aimbat_station aimbat_seismogram.event = aimbat_event - # Create AimbatDataSource instance with relationship to AimbatSeismogram select_aimbat_data_source = select(AimbatDataSource).where( AimbatDataSource.sourcename == str(datasource) ) aimbat_data_source = session.exec(select_aimbat_data_source).one_or_none() if aimbat_data_source is None: logger.debug(f"Adding data source {datasource} to project.") - aimbat_data_source_create = _AimbatDataSourceCreate( - sourcename=str(datasource), datatype=datatype - ) aimbat_data_source = AimbatDataSource.model_validate( - aimbat_data_source_create, + _AimbatDataSourceCreate(sourcename=str(datasource), datatype=datatype), update={"seismogram": aimbat_seismogram}, ) - else: logger.debug( f"Using existing data source {datasource} instead of adding new one." @@ -215,21 +235,43 @@ def add_data_to_project( session: Session, data_sources: Sequence[str | os.PathLike], data_type: DataType, + station_id: uuid.UUID | None = None, + event_id: uuid.UUID | None = None, dry_run: bool = False, disable_progress_bar: bool = True, ) -> None: """Add data sources to the AIMBAT database. + What gets created depends on which capabilities `data_type` supports: + + - Station + event + seismogram: all three records are created and linked, + and an `AimbatDataSource` entry is stored. + - Station or event only (e.g. `JSON_STATION`, `JSON_EVENT`): only the + relevant metadata records are created; no seismogram or data source entry + is stored. + + Use `station_id` or `event_id` to skip extracting station or event metadata + from the data source and link to a pre-existing record instead. + Args: session: The SQLModel database session. data_sources: List of data sources to add. data_type: Type of data. + station_id: UUID of an existing station to use instead of extracting + one from each data source. + event_id: UUID of an existing event to use instead of extracting one + from each data source. dry_run: If True, do not commit changes to the database. disable_progress_bar: Do not display progress bar. """ logger.info(f"Adding {len(data_sources)} {data_type} data sources to project.") + if station_id is not None and session.get(AimbatStation, station_id) is None: + raise ValueError(f"No station found with ID {station_id}.") + if event_id is not None and session.get(AimbatEvent, event_id) is None: + raise ValueError(f"No event found with ID {event_id}.") + # Snapshot existing IDs before entering the savepoint so we can identify # what would be new vs reused when running a dry run. if dry_run: @@ -245,19 +287,22 @@ def add_data_to_project( description="Adding data ...", disable=disable_progress_bar, ): - added_datasources.append( - _add_datasource(session, datasource, data_type) + result = _process_datasource( + session, datasource, data_type, station_id, event_id ) + if result is not None: + added_datasources.append(result) if dry_run: logger.info("Dry run: displaying data that would be added.") - session.flush() - _print_dry_run_results( - added_datasources, - existing_station_ids, - existing_event_ids, - existing_seismogram_ids, - ) + if added_datasources: + session.flush() + _print_dry_run_results( + added_datasources, + existing_station_ids, + existing_event_ids, + existing_seismogram_ids, + ) nested.rollback() logger.info("Dry run complete. Rolling back changes.") return @@ -296,7 +341,7 @@ def print_data_table(session: Session, short: bool, all_events: bool = False) -> Args: short: Shorten UUIDs and format data. - all_events: Print all files instead of limiting to the active event. + all_events: Print all data sources instead of limiting to the active event. """ logger.info("Printing data sources table.") diff --git a/src/aimbat/core/_event.py b/src/aimbat/core/_event.py index a29b8170..0c30a1b8 100644 --- a/src/aimbat/core/_event.py +++ b/src/aimbat/core/_event.py @@ -2,7 +2,7 @@ from aimbat.core import get_active_event from aimbat.logger import logger -from aimbat.cli._common import HINTS +from aimbat._cli.common import HINTS from aimbat.utils import ( uuid_shortener, json_to_table, @@ -16,7 +16,7 @@ _AimbatEventRead, ) from aimbat.models._parameters import AimbatEventParametersBase -from aimbat.aimbat_types import ( +from aimbat._types import ( EventParameter, EventParameterBool, EventParameterFloat, diff --git a/src/aimbat/core/_seismogram.py b/src/aimbat/core/_seismogram.py index acff5dd2..cec82343 100644 --- a/src/aimbat/core/_seismogram.py +++ b/src/aimbat/core/_seismogram.py @@ -12,7 +12,7 @@ AimbatSeismogramParameters, ) from aimbat.models._parameters import AimbatSeismogramParametersBase -from aimbat.aimbat_types import ( +from aimbat._types import ( SeismogramParameter, SeismogramParameterBool, SeismogramParameterTimestamp, diff --git a/src/aimbat/io/__init__.py b/src/aimbat/io/__init__.py index d390d524..d94d3fe2 100644 --- a/src/aimbat/io/__init__.py +++ b/src/aimbat/io/__init__.py @@ -1,21 +1,23 @@ # flake8: noqa: E402, F403 -"""I/O interface for AIMBAT data sources. +# +"""File I/O for AIMBAT. -Data source modules register their read/write and creation capabilities using -the `register_*` functions exported from this package. Not all data sources -need to support all capabilities — a source providing waveform data only would -register a reader and writer but not the creator functions. +Data source modules plug in by calling the `register_*` functions from this +package. Not every source needs to implement everything — a source that only +provides waveform data would register a reader and writer but skip the creator +functions. -The SAC data source (`aimbat.io._sac`) is included and registers its -capabilities automatically. +SAC (`aimbat.io.sac`) and JSON (`aimbat.io.json`) data sources are loaded +automatically and their capabilities registered on import of this package. """ from .._utils import export_module_names _internal_names = set(dir()) +from ._data import * from ._base import * -from ._sac import * +from . import sac as sac, json as json __all__ = [s for s in dir() if not s.startswith("_") and s not in _internal_names] diff --git a/src/aimbat/io/_base.py b/src/aimbat/io/_base.py index e82e2964..aec5973f 100644 --- a/src/aimbat/io/_base.py +++ b/src/aimbat/io/_base.py @@ -11,7 +11,7 @@ """ from __future__ import annotations -from aimbat.aimbat_types import DataType +from ._data import DataType from aimbat.logger import logger from os import PathLike from typing import TYPE_CHECKING, Callable diff --git a/src/aimbat/io/_data.py b/src/aimbat/io/_data.py new file mode 100644 index 00000000..e4c56ad8 --- /dev/null +++ b/src/aimbat/io/_data.py @@ -0,0 +1,18 @@ +from enum import StrEnum, auto + +__all__ = [ + "DataType", +] + + +class DataType(StrEnum): + """Valid AIMBAT data types.""" + + SAC = auto() + """SAC (Seismic Analysis Code) waveform file. Provides station, event, and seismogram data.""" + + JSON_EVENT = auto() + """JSON file containing a single seismic event record.""" + + JSON_STATION = auto() + """JSON file containing a single seismic station record.""" diff --git a/src/aimbat/io/json.py b/src/aimbat/io/json.py new file mode 100644 index 00000000..4542a39e --- /dev/null +++ b/src/aimbat/io/json.py @@ -0,0 +1,91 @@ +"""JSON data source support for AIMBAT. + +Provides station and event creation from JSON files: + +- `JSON_STATION` (`DataType.JSON_STATION`): a JSON file containing a single + station record. Field names match `AimbatStation`: + + ```json + { + "name": "ANMO", + "network": "IU", + "location": "00", + "channel": "BHZ", + "latitude": 34.9459, + "longitude": -106.4572, + "elevation": 1820.0 + } + ``` + +- `JSON_EVENT` (`DataType.JSON_EVENT`): a JSON file containing a single event + record. Field names match `AimbatEvent`: + + ```json + { + "time": "2020-01-01T00:00:00Z", + "latitude": 35.0, + "longitude": -120.0, + "depth": 10.0 + } + ``` +""" + +from __future__ import annotations +import json +from ._data import DataType +from aimbat.logger import logger +from os import PathLike +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aimbat.models import AimbatEvent, AimbatStation + +__all__ = [ + "create_station_from_json", + "create_event_from_json", +] + + +def create_station_from_json(path: str | PathLike) -> AimbatStation: + """Create an `AimbatStation` from a JSON file. + + Args: + path: Path to the JSON file. + + Returns: + A new `AimbatStation` instance. + """ + from aimbat.models import AimbatStation + + logger.debug(f"Reading station data from {path}.") + + with open(path) as f: + data = json.load(f) + return AimbatStation.model_validate(data) + + +def create_event_from_json(path: str | PathLike) -> AimbatEvent: + """Create an `AimbatEvent` from a JSON file. + + Args: + path: Path to the JSON file. + + Returns: + A new `AimbatEvent` instance. + """ + from aimbat.models import AimbatEvent, AimbatEventParameters + + logger.debug(f"Reading event data from {path}.") + + with open(path) as f: + data = json.load(f) + event = AimbatEvent.model_validate(data) + event.parameters = AimbatEventParameters() + return event + + +# Register JSON capabilities with the io dispatch layer +from ._base import register_station_creator, register_event_creator # noqa: E402 + +register_station_creator(DataType.JSON_STATION, create_station_from_json) +register_event_creator(DataType.JSON_EVENT, create_event_from_json) diff --git a/src/aimbat/io/_sac.py b/src/aimbat/io/sac.py similarity index 91% rename from src/aimbat/io/_sac.py rename to src/aimbat/io/sac.py index cb2215ba..be94622a 100644 --- a/src/aimbat/io/_sac.py +++ b/src/aimbat/io/sac.py @@ -1,6 +1,16 @@ +"""SAC file I/O for AIMBAT. + +Reads and writes seismogram data from SAC files via `pysmo`, and creates +`AimbatStation`, `AimbatEvent`, and `AimbatSeismogram` model instances from +SAC file metadata. + +This module registers its capabilities with the I/O dispatch layer on import, +so importing it is sufficient to enable SAC support. +""" + from __future__ import annotations from aimbat import settings -from aimbat.aimbat_types import DataType +from ._data import DataType from aimbat.logger import logger from pysmo.classes import SAC from os import PathLike diff --git a/src/aimbat/models/_models.py b/src/aimbat/models/_models.py index f960de53..8b04fedf 100644 --- a/src/aimbat/models/_models.py +++ b/src/aimbat/models/_models.py @@ -4,9 +4,8 @@ import numpy.typing as npt import os import uuid -from aimbat.io import read_seismogram_data, write_seismogram_data -from aimbat.aimbat_types import ( - DataType, +from aimbat.io import DataType, read_seismogram_data, write_seismogram_data +from aimbat._types import ( PydanticTimestamp, PydanticPositiveTimedelta, SAPandasTimestamp, diff --git a/src/aimbat/models/_parameters.py b/src/aimbat/models/_parameters.py index 4976f9a8..5a7a58b3 100644 --- a/src/aimbat/models/_parameters.py +++ b/src/aimbat/models/_parameters.py @@ -1,7 +1,7 @@ """Base classes defining AIMBAT processing parameters.""" from aimbat import settings -from aimbat.aimbat_types import ( +from aimbat._types import ( PydanticTimestamp, PydanticNegativeTimedelta, PydanticPositiveTimedelta, diff --git a/src/aimbat/utils/__init__.py b/src/aimbat/utils/__init__.py index 0e2c4a85..7be6a982 100644 --- a/src/aimbat/utils/__init__.py +++ b/src/aimbat/utils/__init__.py @@ -1,5 +1,14 @@ # flake8: noqa: E402, F403 -"""Utils used in AIMBAT.""" +"""Miscellaneous helpers for AIMBAT. + +Covers four areas: + +- **JSON** — render JSON data as Rich tables (`json_to_table`). +- **Sample data** — download and delete the bundled sample dataset + (`download_sampledata`, `delete_sampledata`). +- **Styling** — shared Rich/table style helpers (`make_table`). +- **UUIDs** — look up model records by short UUID prefix (`get_by_uuid`). +""" from .._utils import export_module_names diff --git a/tests/conftest.py b/tests/conftest.py index 445cfe04..4df07e66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ import os import subprocess from aimbat.app import app -from aimbat.aimbat_types import DataType +from aimbat.io import DataType from aimbat.core import add_data_to_project, set_active_event, create_project from aimbat.models import AimbatEvent from aimbat.logger import configure_logging diff --git a/tests/integration/core/test_data.py b/tests/integration/core/test_data.py new file mode 100644 index 00000000..2dafbce6 --- /dev/null +++ b/tests/integration/core/test_data.py @@ -0,0 +1,708 @@ +"""Integration tests for adding data to the project (aimbat.core._data).""" + +import json +import uuid +import pytest +from pathlib import Path +from pandas import Timestamp +from sqlalchemy import Engine +from sqlmodel import Session, select +from pydantic import ValidationError +from pysmo.classes import SAC +from aimbat.io import DataType +from aimbat.core import ( + add_data_to_project, + get_data_for_active_event, + print_data_table, + dump_data_table_to_json, +) +from aimbat.models import ( + AimbatDataSource, + AimbatEvent, + AimbatSeismogram, + AimbatStation, +) +from collections.abc import Generator + +# --------------------------------------------------------------------------- +# Module-level fixtures +# --------------------------------------------------------------------------- + + +_STATION_DATA: dict = { + "name": "ANMO", + "network": "IU", + "location": "00", + "channel": "BHZ", + "latitude": 34.9459, + "longitude": -106.4572, + "elevation": 1820.0, +} + +_EVENT_DATA: dict = { + "time": "2020-01-01T00:00:00Z", + "latitude": 35.0, + "longitude": -120.0, + "depth": 10.0, +} + + +@pytest.fixture() +def station_json(tmp_path: Path) -> Path: + """Path to a temporary JSON file containing a station record. + + Args: + tmp_path: The pytest tmp_path fixture. + + Returns: + Path to the JSON station file. + """ + path = tmp_path / "station.json" + path.write_text(json.dumps(_STATION_DATA)) + return path + + +@pytest.fixture() +def event_json(tmp_path: Path) -> Path: + """Path to a temporary JSON file containing an event record. + + Args: + tmp_path: The pytest tmp_path fixture. + + Returns: + Path to the JSON event file. + """ + path = tmp_path / "event.json" + path.write_text(json.dumps(_EVENT_DATA)) + return path + + +# =================================================================== +# Session-level tests (patched_session / loaded_session) +# =================================================================== + + +class TestAddDataToProject: + @pytest.fixture + def session(self, patched_session: Session) -> Generator[Session, None, None]: + """Provides a database session for tests. + + Args: + patched_session (Session): A patched SQLAlchemy session fixture. + """ + yield patched_session + + def test_add_single_sac_file(self, sac_file_good: Path, session: Session) -> None: + """Verifies adding a single valid SAC file to the project. + + Args: + sac_file_good (Path): Path to a valid SAC file. + session (Session): Database session. + """ + datasource = session.exec(select(AimbatDataSource.sourcename)).all() + assert len(datasource) == 0, "Expected no data sources before adding files." + + # do this 2 times to verify we can only add the same file once and that nothing changes on the second attempt + for _ in range(2): + add_data_to_project( + session, + [sac_file_good], + data_type=DataType.SAC, + ) + seismogram_filename = session.exec( + select(AimbatDataSource.sourcename) + ).one() + assert seismogram_filename == str(sac_file_good) + + def test_add_multiple_sac_files( + self, multi_event_data: list[Path], session: Session + ) -> None: + """Verifies adding multiple SAC files to the project at once. + + Args: + multi_event_data (list[Path]): List of paths to SAC files. + session (Session): Database session. + """ + datasource = session.exec(select(AimbatDataSource.sourcename)).all() + assert len(datasource) == 0, "Expected no data sources before adding files." + + add_data_to_project( + session, + multi_event_data, + data_type=DataType.SAC, + ) + + seismogram_filenames = session.exec(select(AimbatDataSource.sourcename)).all() + assert sorted(seismogram_filenames) == sorted( + [str(path) for path in multi_event_data] + ), "Expected all files from multi_event to be added as data sources." + + def test_add_nonexistent_file(self, session: Session) -> None: + """Verifies that adding a non-existent file raises FileNotFoundError. + + Args: + session (Session): Database session. + """ + non_existent_file = Path("this_file_does_not_exist.sac") + with pytest.raises(FileNotFoundError): + add_data_to_project( + session, + [non_existent_file], + data_type=DataType.SAC, + ) + + def test_add_mixed_valid_and_invalid_files( + self, sac_file_good: Path, session: Session + ) -> None: + """Verifies that adding a mix of valid and invalid files raises an error and adds nothing. + + Args: + sac_file_good (Path): Path to a valid SAC file. + session (Session): Database session. + """ + non_existent_file = Path("this_file_does_not_exist.sac") + with pytest.raises(FileNotFoundError): + add_data_to_project( + session, + [sac_file_good, non_existent_file], + data_type=DataType.SAC, + ) + + datasource = session.exec(select(AimbatDataSource.sourcename)).all() + assert ( + len(datasource) == 0 + ), "Expected no data sources to be added when an error occurs." + + def test_add_sac_file_with_missing_pick( + self, sac_file_good: Path, session: Session + ) -> None: + """Verifies that adding a SAC file missing required pick information raises ValidationError. + + Args: + sac_file_good (Path): Path to a valid SAC file. + session (Session): Database session. + """ + sac = SAC.from_file(sac_file_good) + sac.timestamps.t0 = None + sac.write(sac_file_good) + with pytest.raises(ValidationError): + add_data_to_project( + session, + [sac_file_good], + data_type=DataType.SAC, + ) + + def test_dry_run_all_new( + self, + multi_event_data: list[Path], + session: Session, + capsys: pytest.CaptureFixture, + ) -> None: + """Verifies dry run behaviour when all data is new. + + Args: + multi_event_data (list[Path]): List of paths to SAC files. + session (Session): Database session. + capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. + """ + add_data_to_project( + session, + multi_event_data, + data_type=DataType.SAC, + dry_run=True, + ) + + datasource = session.exec(select(AimbatDataSource.sourcename)).all() + assert len(datasource) == 0, "Expected no data sources after dry run." + + captured = capsys.readouterr() + assert "Dry Run: Data to be added" in captured.out + n = len(multi_event_data) + assert f"{n} seismogram(s) added, 0 skipped" in captured.out + assert "0 skipped" in captured.out + + def test_dry_run_all_skipped( + self, + multi_event_data: list[Path], + session: Session, + capsys: pytest.CaptureFixture, + ) -> None: + """Verifies dry run behaviour when all data already exists (should be skipped). + + Args: + multi_event_data (list[Path]): List of paths to SAC files. + session (Session): Database session. + capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. + """ + add_data_to_project( + session, + multi_event_data, + data_type=DataType.SAC, + ) + capsys.readouterr() + + add_data_to_project( + session, + multi_event_data, + data_type=DataType.SAC, + dry_run=True, + ) + + captured = capsys.readouterr() + assert "Dry Run: Data to be added" in captured.out + n = len(multi_event_data) + assert f"0 station(s) added, {n} skipped" in captured.out + assert f"0 event(s) added, {n} skipped" in captured.out + assert f"0 seismogram(s) added, {n} skipped" in captured.out + + +class TestGetDataSources: + @pytest.fixture + def session(self, loaded_session: Session) -> Generator[Session, None, None]: + """Provides a database session with pre-loaded data sources for tests. + + Args: + loaded_session (Session): A SQLAlchemy session fixture with pre-loaded data sources. + """ + yield loaded_session + + def test_get_data_sources_for_active_event(self, session: Session) -> None: + """Verifies that get_data_sources returns the expected data sources. + + Args: + session (Session): Database session. + """ + data_sources = get_data_for_active_event(session) + assert len(data_sources) != 0, "Expected data sources for the active event." + assert all( + isinstance(ds, AimbatDataSource) for ds in data_sources + ), "expected all items to be AimbatDataSource instances" + + def test_dump_data_table_to_json(self, session: Session) -> None: + """Verifies that dump_data_table_to_json returns a JSON string with expected content. + + Args: + session (Session): Database session. + """ + json_str = dump_data_table_to_json(session) + json_data = json.loads(json_str) + assert isinstance(json_data, list), "Expected JSON data to be a list." + + expected_ids = map(str, session.exec(select(AimbatDataSource.id)).all()) + returned_ids = [item["id"] for item in json_data] + assert set(expected_ids) == set(returned_ids), "Expected IDs to match." + + def test_print_data_table_for_all_events( + self, session: Session, capsys: pytest.CaptureFixture + ) -> None: + """Verifies that print_data_table produces output for all events. + + Args: + session (Session): Database session. + capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. + """ + print_data_table(session, short=False, all_events=True) + + expected_ids = session.exec(select(AimbatDataSource.id)).all() + + captured = capsys.readouterr() + assert "Data sources for all events" in captured.out + for id in expected_ids: + assert ( + str(id) in captured.out + ), "expected data source ID to be in the output table" + + def test_print_data_table_for_all_events_short( + self, session: Session, capsys: pytest.CaptureFixture + ) -> None: + """Verifies that print_data_table produces short output for all events. + + Args: + session (Session): Database session. + capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. + """ + expected_ids = session.exec(select(AimbatDataSource.id)).all() + + print_data_table(session, short=True, all_events=True) + + captured = capsys.readouterr() + assert "Data sources for all events" in captured.out + for id in expected_ids: + assert ( + str(id)[:2] in captured.out + ), "expected data source ID to be in the output table" + + def test_print_data_table_for_active_event( + self, session: Session, capsys: pytest.CaptureFixture + ) -> None: + """Verifies that print_data_table produces output for the active event. + + Args: + session (Session): Database session. + capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. + """ + statement = ( + select(AimbatDataSource.id) + .join(AimbatSeismogram) + .join(AimbatEvent) + .where(AimbatEvent.active == 1) + ) + expected_ids = session.exec(statement).all() + + print_data_table(session, short=False, all_events=False) + + captured = capsys.readouterr() + assert "Data sources for event" in captured.out + for id in expected_ids: + assert ( + str(id) in captured.out + ), "expected data source ID to be in the output table" + + def test_print_data_table_for_active_event_short( + self, session: Session, capsys: pytest.CaptureFixture + ) -> None: + """Verifies that print_data_table produces short output for the active event. + + Args: + session (Session): Database session. + capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. + """ + statement = ( + select(AimbatDataSource.id) + .join(AimbatSeismogram) + .join(AimbatEvent) + .where(AimbatEvent.active == 1) + ) + expected_ids = session.exec(statement).all() + + print_data_table(session, short=True, all_events=False) + + captured = capsys.readouterr() + assert "Data sources for event" in captured.out + for id in expected_ids: + assert ( + str(id)[:2] in captured.out + ), "expected data source ID to be in the output table" + + +# =================================================================== +# Engine-level tests (add_data_to_project with engine fixture) +# =================================================================== + + +class TestAddDataSac: + """Tests for add_data_to_project with SAC data.""" + + def test_creates_station_event_seismogram_and_datasource( + self, engine: Engine, sac_file_good: Path + ) -> None: + """Verifies that a SAC import creates all four entity types. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + """ + with Session(engine) as session: + add_data_to_project(session, [sac_file_good], DataType.SAC) + + with Session(engine) as session: + assert len(session.exec(select(AimbatStation)).all()) == 1 + assert len(session.exec(select(AimbatEvent)).all()) == 1 + assert len(session.exec(select(AimbatSeismogram)).all()) == 1 + assert len(session.exec(select(AimbatDataSource)).all()) == 1 + + def test_duplicate_import_does_not_create_duplicates( + self, engine: Engine, sac_file_good: Path + ) -> None: + """Verifies that importing the same SAC file twice does not duplicate records. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + """ + with Session(engine) as session: + add_data_to_project(session, [sac_file_good], DataType.SAC) + add_data_to_project(session, [sac_file_good], DataType.SAC) + + with Session(engine) as session: + assert len(session.exec(select(AimbatStation)).all()) == 1 + assert len(session.exec(select(AimbatEvent)).all()) == 1 + assert len(session.exec(select(AimbatSeismogram)).all()) == 1 + assert len(session.exec(select(AimbatDataSource)).all()) == 1 + + +class TestAddDataJsonStation: + """Tests for add_data_to_project with JSON_STATION data.""" + + def test_creates_station_only(self, engine: Engine, station_json: Path) -> None: + """Verifies that a JSON_STATION import creates only a station record. + + Args: + engine: In-memory SQLAlchemy Engine. + station_json: Path to a valid JSON station file. + """ + with Session(engine) as session: + add_data_to_project(session, [station_json], DataType.JSON_STATION) + + with Session(engine) as session: + assert len(session.exec(select(AimbatStation)).all()) == 1 + assert len(session.exec(select(AimbatEvent)).all()) == 0 + assert len(session.exec(select(AimbatSeismogram)).all()) == 0 + assert len(session.exec(select(AimbatDataSource)).all()) == 0 + + def test_station_fields_match_json( + self, engine: Engine, station_json: Path + ) -> None: + """Verifies that imported station fields match the JSON values. + + Args: + engine: In-memory SQLAlchemy Engine. + station_json: Path to a valid JSON station file. + """ + with Session(engine) as session: + add_data_to_project(session, [station_json], DataType.JSON_STATION) + + with Session(engine) as session: + station = session.exec(select(AimbatStation)).one() + assert station.name == _STATION_DATA["name"] + assert station.network == _STATION_DATA["network"] + assert station.location == _STATION_DATA["location"] + assert station.channel == _STATION_DATA["channel"] + assert station.latitude == _STATION_DATA["latitude"] + + +class TestAddDataJsonEvent: + """Tests for add_data_to_project with JSON_EVENT data.""" + + def test_creates_event_only(self, engine: Engine, event_json: Path) -> None: + """Verifies that a JSON_EVENT import creates only an event record. + + Args: + engine: In-memory SQLAlchemy Engine. + event_json: Path to a valid JSON event file. + """ + with Session(engine) as session: + add_data_to_project(session, [event_json], DataType.JSON_EVENT) + + with Session(engine) as session: + assert len(session.exec(select(AimbatStation)).all()) == 0 + assert len(session.exec(select(AimbatEvent)).all()) == 1 + assert len(session.exec(select(AimbatSeismogram)).all()) == 0 + assert len(session.exec(select(AimbatDataSource)).all()) == 0 + + def test_event_fields_match_json(self, engine: Engine, event_json: Path) -> None: + """Verifies that imported event fields match the JSON values. + + Args: + engine: In-memory SQLAlchemy Engine. + event_json: Path to a valid JSON event file. + """ + with Session(engine) as session: + add_data_to_project(session, [event_json], DataType.JSON_EVENT) + + with Session(engine) as session: + event = session.exec(select(AimbatEvent)).one() + assert event.time == Timestamp("2020-01-01T00:00:00Z") + assert event.latitude == _EVENT_DATA["latitude"] + assert event.longitude == _EVENT_DATA["longitude"] + assert event.depth == _EVENT_DATA["depth"] + + def test_event_has_parameters(self, engine: Engine, event_json: Path) -> None: + """Verifies that the imported event has initialised parameters. + + Args: + engine: In-memory SQLAlchemy Engine. + event_json: Path to a valid JSON event file. + """ + with Session(engine) as session: + add_data_to_project(session, [event_json], DataType.JSON_EVENT) + + with Session(engine) as session: + event = session.exec(select(AimbatEvent)).one() + assert event.parameters is not None + + +class TestUuidValidation: + """Tests for early UUID validation in add_data_to_project.""" + + def test_invalid_station_id_raises_value_error( + self, engine: Engine, sac_file_good: Path + ) -> None: + """Verifies that a non-existent station UUID raises ValueError before the import loop. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + """ + with Session(engine) as session: + with pytest.raises(ValueError, match="No station found"): + add_data_to_project( + session, + [sac_file_good], + DataType.SAC, + station_id=uuid.uuid4(), + ) + + def test_invalid_event_id_raises_value_error( + self, engine: Engine, sac_file_good: Path + ) -> None: + """Verifies that a non-existent event UUID raises ValueError before the import loop. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + """ + with Session(engine) as session: + with pytest.raises(ValueError, match="No event found"): + add_data_to_project( + session, + [sac_file_good], + DataType.SAC, + event_id=uuid.uuid4(), + ) + + def test_invalid_uuid_does_not_modify_database( + self, engine: Engine, sac_file_good: Path + ) -> None: + """Verifies that a failed UUID check leaves the database unchanged. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + """ + with Session(engine) as session: + with pytest.raises(ValueError): + add_data_to_project( + session, + [sac_file_good], + DataType.SAC, + station_id=uuid.uuid4(), + ) + + with Session(engine) as session: + assert len(session.exec(select(AimbatStation)).all()) == 0 + assert len(session.exec(select(AimbatEvent)).all()) == 0 + + +class TestCombinedSacAndJsonStation: + """Tests for combining SAC seismogram data with a JSON-imported station.""" + + def test_sac_with_station_id_links_to_json_station( + self, engine: Engine, sac_file_good: Path, station_json: Path + ) -> None: + """Verifies that a SAC import with station_id links to the pre-existing station. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + station_json: Path to a valid JSON station file. + """ + with Session(engine) as session: + add_data_to_project(session, [station_json], DataType.JSON_STATION) + station = session.exec(select(AimbatStation)).one() + station_id = station.id + + with Session(engine) as session: + add_data_to_project( + session, [sac_file_good], DataType.SAC, station_id=station_id + ) + + with Session(engine) as session: + seismogram = session.exec(select(AimbatSeismogram)).one() + assert seismogram.station_id == station_id + + def test_sac_with_station_id_does_not_create_extra_station( + self, engine: Engine, sac_file_good: Path, station_json: Path + ) -> None: + """Verifies that the SAC file's embedded station data is ignored when station_id is provided. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + station_json: Path to a valid JSON station file. + """ + with Session(engine) as session: + add_data_to_project(session, [station_json], DataType.JSON_STATION) + station = session.exec(select(AimbatStation)).one() + station_id = station.id + + with Session(engine) as session: + add_data_to_project( + session, [sac_file_good], DataType.SAC, station_id=station_id + ) + + with Session(engine) as session: + assert len(session.exec(select(AimbatStation)).all()) == 1 + + +class TestCombinedSacAndJsonEvent: + """Tests for combining SAC seismogram data with a JSON-imported event.""" + + def test_sac_with_event_id_links_to_json_event( + self, engine: Engine, sac_file_good: Path, event_json: Path + ) -> None: + """Verifies that a SAC import with event_id links to the pre-existing event. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + event_json: Path to a valid JSON event file. + """ + with Session(engine) as session: + add_data_to_project(session, [event_json], DataType.JSON_EVENT) + event = session.exec(select(AimbatEvent)).one() + event_id = event.id + + with Session(engine) as session: + add_data_to_project( + session, [sac_file_good], DataType.SAC, event_id=event_id + ) + + with Session(engine) as session: + seismogram = session.exec(select(AimbatSeismogram)).one() + assert seismogram.event_id == event_id + + def test_sac_with_event_id_does_not_create_extra_event( + self, engine: Engine, sac_file_good: Path, event_json: Path + ) -> None: + """Verifies that the SAC file's embedded event data is ignored when event_id is provided. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + event_json: Path to a valid JSON event file. + """ + with Session(engine) as session: + add_data_to_project(session, [event_json], DataType.JSON_EVENT) + event = session.exec(select(AimbatEvent)).one() + event_id = event.id + + with Session(engine) as session: + add_data_to_project( + session, [sac_file_good], DataType.SAC, event_id=event_id + ) + + with Session(engine) as session: + assert len(session.exec(select(AimbatEvent)).all()) == 1 + + +class TestDryRun: + """Tests for the dry_run option.""" + + def test_dry_run_does_not_persist_changes( + self, engine: Engine, sac_file_good: Path + ) -> None: + """Verifies that dry_run=True rolls back all changes. + + Args: + engine: In-memory SQLAlchemy Engine. + sac_file_good: Path to a valid SAC file. + """ + with Session(engine) as session: + add_data_to_project(session, [sac_file_good], DataType.SAC, dry_run=True) + + with Session(engine) as session: + assert len(session.exec(select(AimbatDataSource)).all()) == 0 + assert len(session.exec(select(AimbatSeismogram)).all()) == 0 + assert len(session.exec(select(AimbatStation)).all()) == 0 + assert len(session.exec(select(AimbatEvent)).all()) == 0 diff --git a/tests/integration/test_event.py b/tests/integration/core/test_event.py similarity index 67% rename from tests/integration/test_event.py rename to tests/integration/core/test_event.py index 721992a1..1360eeab 100644 --- a/tests/integration/test_event.py +++ b/tests/integration/core/test_event.py @@ -1,8 +1,10 @@ -"""Integration tests for event management functions in aimbat.core._event.""" +"""Integration tests for event management functions in aimbat.core.""" 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 from aimbat.core._event import ( delete_event, delete_event_by_id, @@ -15,7 +17,7 @@ print_event_table, print_event_parameter_table, ) -from aimbat.aimbat_types import EventParameter +from aimbat._types import EventParameter from aimbat.models import AimbatEvent, AimbatStation from pandas import Timedelta from sqlmodel import Session, select @@ -35,6 +37,135 @@ def session(loaded_session: Session) -> Session: return loaded_session +# =================================================================== +# Active event +# =================================================================== + + +class TestActiveEvent: + """Tests for retrieving and switching the active event.""" + + def test_get(self, session: Session) -> None: + """Verifies that `get_active_event` returns the event marked as active in the DB. + + Args: + session (Session): The database session. + """ + active_event = session.exec( + select(AimbatEvent).where(AimbatEvent.active == 1) + ).one() + assert active_event == get_active_event(session) + + def test_switch(self, session: Session) -> None: + """Verifies switching the active 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" + + 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() + assert ( + new_active_event != active_event + ), "expected a different event to switch to" + + set_active_event(session, new_active_event) + assert get_active_event(session) == new_active_event + + def test_switch_by_id(self, session: Session) -> None: + """Verifies switching the active event using an event ID. + + Args: + session (Session): The database session. + """ + active_event = get_active_event(session) + event_ids = list(session.exec(select(AimbatEvent.id)).all()) + + event_ids.remove(active_event.id) + new_active_event_id = event_ids.pop() + assert ( + new_active_event_id != active_event.id + ), "expected a different event id to switch to" + + set_active_event_by_id(session, new_active_event_id) + + assert ( + get_active_event(session).id == new_active_event_id + ), "expected the active 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.""" + + new_uuid = uuid.uuid4() + assert ( + len( + session.exec( + select(AimbatEvent).where(AimbatEvent.id == new_uuid) + ).all() + ) + == 0 + ), "expected no event with the generated UUID in the test data" + + with pytest.raises(ValueError): + set_active_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. + + 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 + assert ( + session.exec(select(AimbatEvent).where(AimbatEvent.active == 1)).first() + is None + ), "expected no active event in the database after deactivating" + + with pytest.raises(NoResultFound): + get_active_event(session) + + +# =================================================================== +# Delete event +# =================================================================== + + class TestDeleteEvent: """Tests for deleting events from the database.""" @@ -79,6 +210,11 @@ def test_delete_event_by_id_not_found(self, session: Session) -> None: delete_event_by_id(session, uuid.uuid4()) +# =================================================================== +# Query events +# =================================================================== + + class TestGetCompletedEvents: """Tests for retrieving events marked as completed.""" @@ -147,6 +283,11 @@ def test_get_events_using_station_no_match(self, session: Session) -> None: assert len(events) == 0 +# =================================================================== +# Event parameters +# =================================================================== + + class TestGetEventParameter: """Tests for reading parameter values from the active event.""" @@ -211,6 +352,11 @@ def test_set_bool_parameter(self, session: Session) -> None: assert get_event_parameter(session, EventParameter.COMPLETED) is True +# =================================================================== +# JSON serialisation +# =================================================================== + + class TestDumpEventTableToJson: """Tests for serialising the event table to JSON.""" @@ -299,6 +445,11 @@ def test_all_events_as_list(self, session: Session) -> None: assert "min_ccnorm" in result[0] +# =================================================================== +# Print tables +# =================================================================== + + class TestPrintEventTable: """Tests for printing the event table.""" diff --git a/tests/integration/test_project.py b/tests/integration/core/test_project.py similarity index 100% rename from tests/integration/test_project.py rename to tests/integration/core/test_project.py diff --git a/tests/integration/test_seismogram.py b/tests/integration/core/test_seismogram.py similarity index 99% rename from tests/integration/test_seismogram.py rename to tests/integration/core/test_seismogram.py index 2f802ed0..19a0cb35 100644 --- a/tests/integration/test_seismogram.py +++ b/tests/integration/core/test_seismogram.py @@ -17,7 +17,7 @@ print_seismogram_parameter_table, plot_all_seismograms, ) -from aimbat.aimbat_types import SeismogramParameter +from aimbat._types import SeismogramParameter from aimbat.models import AimbatSeismogram from matplotlib.figure import Figure from pandas import Timestamp diff --git a/tests/integration/test_snapshots.py b/tests/integration/core/test_snapshots.py similarity index 100% rename from tests/integration/test_snapshots.py rename to tests/integration/core/test_snapshots.py diff --git a/tests/integration/test_station.py b/tests/integration/core/test_station.py similarity index 100% rename from tests/integration/test_station.py rename to tests/integration/core/test_station.py diff --git a/tests/integration/test_datasource_sac.py b/tests/integration/io/test_datasource_sac.py similarity index 99% rename from tests/integration/test_datasource_sac.py rename to tests/integration/io/test_datasource_sac.py index 06567927..04c2c738 100644 --- a/tests/integration/test_datasource_sac.py +++ b/tests/integration/io/test_datasource_sac.py @@ -20,7 +20,7 @@ AimbatSeismogramParameters, AimbatStation, ) -from aimbat.aimbat_types import DataType +from aimbat.io import DataType from datetime import timezone from pathlib import Path from pandas import Timestamp diff --git a/tests/integration/test_models.py b/tests/integration/models/test_models.py similarity index 99% rename from tests/integration/test_models.py rename to tests/integration/models/test_models.py index f94a744b..e93e1950 100644 --- a/tests/integration/test_models.py +++ b/tests/integration/models/test_models.py @@ -17,7 +17,7 @@ AimbatStation, ) from aimbat.models._parameters import AimbatEventParametersBase -from aimbat.aimbat_types import DataType +from aimbat.io import DataType from datetime import timezone from pandas import Timedelta, Timestamp from pydantic import ValidationError diff --git a/tests/integration/test_db_operations.py b/tests/integration/models/test_operations.py similarity index 100% rename from tests/integration/test_db_operations.py rename to tests/integration/models/test_operations.py diff --git a/tests/integration/test_active_event.py b/tests/integration/test_active_event.py deleted file mode 100644 index 6a4aac55..00000000 --- a/tests/integration/test_active_event.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Integration tests for managing the active event in the database.""" - -import pytest -import uuid -from unittest.mock import patch -from aimbat.core import set_active_event, set_active_event_by_id, get_active_event -from aimbat.models import AimbatEvent -from sqlmodel import Session, select -from sqlalchemy.exc import NoResultFound - -# ----------------------------------------------------------------------------- -# Do all tests with the session fixture that has multi_event data pre-loaded -# ----------------------------------------------------------------------------- - - -@pytest.fixture -def session(loaded_session: Session) -> Session: - """Provides a session with multi_event data pre-loaded. - - Args: - loaded_session (Session): The session fixture with data. - - Returns: - Session: The database session. - """ - return loaded_session - - -class TestActiveEvent: - """Tests for retrieving and switching the active event.""" - - def test_get(self, session: Session) -> None: - """Verifies that `get_active_event` returns the event marked as active in the DB. - - Args: - session (Session): The database session. - """ - active_event = session.exec( - select(AimbatEvent).where(AimbatEvent.active == 1) - ).one() - assert active_event == get_active_event(session) - - def test_switch(self, session: Session) -> None: - """Verifies switching the active 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" - - 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() - assert ( - new_active_event != active_event - ), "expected a different event to switch to" - - set_active_event(session, new_active_event) - assert get_active_event(session) == new_active_event - - def test_switch_by_id(self, session: Session) -> None: - """Verifies switching the active event using an event ID. - - Args: - session (Session): The database session. - """ - active_event = get_active_event(session) - event_ids = list(session.exec(select(AimbatEvent.id)).all()) - - event_ids.remove(active_event.id) - new_active_event_id = event_ids.pop() - assert ( - new_active_event_id != active_event.id - ), "expected a different event id to switch to" - - set_active_event_by_id(session, new_active_event_id) - - assert ( - get_active_event(session).id == new_active_event_id - ), "expected the active 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.""" - - new_uuid = uuid.uuid4() - assert ( - len( - session.exec( - select(AimbatEvent).where(AimbatEvent.id == new_uuid) - ).all() - ) - == 0 - ), "expected no event with the generated UUID in the test data" - - with pytest.raises(ValueError): - set_active_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. - - 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 - assert ( - session.exec(select(AimbatEvent).where(AimbatEvent.active == 1)).first() - is None - ), "expected no active event in the database after deactivating" - - with pytest.raises(NoResultFound): - get_active_event(session) diff --git a/tests/integration/test_data_io.py b/tests/integration/test_data_io.py deleted file mode 100644 index 20abdc34..00000000 --- a/tests/integration/test_data_io.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Integration tests for adding data (SAC files) to the project.""" - -import pytest -import json -from aimbat.core import ( - add_data_to_project, - get_data_for_active_event, - print_data_table, - dump_data_table_to_json, -) -from aimbat.aimbat_types import DataType -from aimbat.models import AimbatDataSource, AimbatEvent, AimbatSeismogram -from pysmo.classes import SAC -from pathlib import Path -from sqlmodel import Session, select -from pydantic import ValidationError -from collections.abc import Generator - - -class TestAddDataToProject: - @pytest.fixture - def session(self, patched_session: Session) -> Generator[Session, None, None]: - """Provides a database session for tests. - - Args: - patched_session (Session): A patched SQLAlchemy session fixture. - """ - yield patched_session - - def test_add_single_sac_file(self, sac_file_good: Path, session: Session) -> None: - """Verifies adding a single valid SAC file to the project. - - Args: - sac_file_good (Path): Path to a valid SAC file. - session (Session): Database session. - """ - datasource = session.exec(select(AimbatDataSource.sourcename)).all() - assert len(datasource) == 0, "Expected no data sources before adding files." - - # do this 2 times to verify we can only add the same file once and that nothing changes on the second attempt - for _ in range(2): - add_data_to_project( - session, - [sac_file_good], - data_type=DataType.SAC, - ) - seismogram_filename = session.exec( - select(AimbatDataSource.sourcename) - ).one() - assert seismogram_filename == str(sac_file_good) - - def test_add_multiple_sac_files( - self, multi_event_data: list[Path], session: Session - ) -> None: - """Verifies adding multiple SAC files to the project at once. - - Args: - multi_event_data (list[Path]): List of paths to SAC files. - session (Session): Database session. - """ - datasource = session.exec(select(AimbatDataSource.sourcename)).all() - assert len(datasource) == 0, "Expected no data sources before adding files." - - add_data_to_project( - session, - multi_event_data, - data_type=DataType.SAC, - ) - - seismogram_filenames = session.exec(select(AimbatDataSource.sourcename)).all() - assert sorted(seismogram_filenames) == sorted( - [str(path) for path in multi_event_data] - ), "Expected all files from multi_event to be added as data sources." - - def test_add_nonexistent_file(self, session: Session) -> None: - """Verifies that adding a non-existent file raises FileNotFoundError. - - Args: - session (Session): Database session. - """ - non_existent_file = Path("this_file_does_not_exist.sac") - with pytest.raises(FileNotFoundError): - add_data_to_project( - session, - [non_existent_file], - data_type=DataType.SAC, - ) - - def test_add_mixed_valid_and_invalid_files( - self, sac_file_good: Path, session: Session - ) -> None: - """Verifies that adding a mix of valid and invalid files raises an error and adds nothing. - - Args: - sac_file_good (Path): Path to a valid SAC file. - session (Session): Database session. - """ - non_existent_file = Path("this_file_does_not_exist.sac") - with pytest.raises(FileNotFoundError): - add_data_to_project( - session, - [sac_file_good, non_existent_file], - data_type=DataType.SAC, - ) - - # Verify that the valid file was not added due to the error - datasource = session.exec(select(AimbatDataSource.sourcename)).all() - assert ( - len(datasource) == 0 - ), "Expected no data sources to be added when an error occurs." - - def test_add_sac_file_with_missing_pick( - self, sac_file_good: Path, session: Session - ) -> None: - """Verifies that adding a SAC file missing required pick information raises ValidationError. - - Args: - sac_file_good (Path): Path to a valid SAC file. - session (Session): Database session. - """ - sac = SAC.from_file(sac_file_good) - sac.timestamps.t0 = None - sac.write(sac_file_good) - with pytest.raises(ValidationError): - add_data_to_project( - session, - [sac_file_good], - data_type=DataType.SAC, - ) - - def test_dry_run_all_new( - self, - multi_event_data: list[Path], - session: Session, - capsys: pytest.CaptureFixture, - ) -> None: - """Verifies dry run behavior when all data is new. - - Args: - multi_event_data (list[Path]): List of paths to SAC files. - session (Session): Database session. - capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. - """ - add_data_to_project( - session, - multi_event_data, - data_type=DataType.SAC, - dry_run=True, - ) - - datasource = session.exec(select(AimbatDataSource.sourcename)).all() - assert len(datasource) == 0, "Expected no data sources after dry run." - - captured = capsys.readouterr() - assert "Dry Run: Data to be added" in captured.out - n = len(multi_event_data) - assert f"{n} seismogram(s) added, 0 skipped" in captured.out - assert "0 skipped" in captured.out - - def test_dry_run_all_skipped( - self, - multi_event_data: list[Path], - session: Session, - capsys: pytest.CaptureFixture, - ) -> None: - """Verifies dry run behavior when all data already exists (should be skipped). - - Args: - multi_event_data (list[Path]): List of paths to SAC files. - session (Session): Database session. - capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. - """ - add_data_to_project( - session, - multi_event_data, - data_type=DataType.SAC, - ) - capsys.readouterr() # discard output from the real add - - add_data_to_project( - session, - multi_event_data, - data_type=DataType.SAC, - dry_run=True, - ) - - captured = capsys.readouterr() - assert "Dry Run: Data to be added" in captured.out - n = len(multi_event_data) - assert f"0 station(s) added, {n} skipped" in captured.out - assert f"0 event(s) added, {n} skipped" in captured.out - assert f"0 seismogram(s) added, {n} skipped" in captured.out - - -class TestGetDataSources: - @pytest.fixture - def session(self, loaded_session: Session) -> Generator[Session, None, None]: - """Provides a database session with pre-loaded data sources for tests. - - Args: - loaded_session (Session): A SQLAlchemy session fixture with pre-loaded data sources. - """ - yield loaded_session - - def test_get_data_sources_for_active_event(self, session: Session) -> None: - """Verifies that get_data_sources returns the expected data sources. - - Args: - session (Session): Database session. - """ - - data_sources = get_data_for_active_event(session) - assert len(data_sources) != 0, "Expected data sources for the active event." - assert all( - isinstance(ds, AimbatDataSource) for ds in data_sources - ), "expected all items to be AimbatDataSource instances" - - def test_dump_data_table_to_json(self, session: Session) -> None: - """Verifies that dump_data_table_to_json returns a JSON string with expected content. - - Args: - session (Session): Database session. - """ - json_str = dump_data_table_to_json(session) - json_data = json.loads(json_str) - assert isinstance(json_data, list), "Expected JSON data to be a list." - - expected_ids = map(str, session.exec(select(AimbatDataSource.id)).all()) - returned_ids = [item["id"] for item in json_data] - assert set(expected_ids) == set(returned_ids), "Expected IDs to match." - - def test_print_data_table_for_all_events( - self, session: Session, capsys: pytest.CaptureFixture - ) -> None: - """Verifies that get_data_sources prints the expected table output. - - Args: - session (Session): Database session. - capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. - """ - print_data_table(session, short=False, all_events=True) - - expected_ids = session.exec(select(AimbatDataSource.id)).all() - - captured = capsys.readouterr() - assert "Data sources for all events" in captured.out - for id in expected_ids: - assert ( - str(id) in captured.out - ), "expected data source ID to be in the output table" - - def test_print_data_table_for_all_events_short( - self, session: Session, capsys: pytest.CaptureFixture - ) -> None: - """Verifies that get_data_sources prints the expected table output. - - Args: - session (Session): Database session. - capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. - """ - - expected_ids = session.exec(select(AimbatDataSource.id)).all() - - print_data_table(session, short=True, all_events=True) - - captured = capsys.readouterr() - assert "Data sources for all events" in captured.out - for id in expected_ids: - assert ( - str(id)[:2] in captured.out - ), "expected data source ID to be in the output table" - - def test_print_data_table_for_active_event( - self, session: Session, capsys: pytest.CaptureFixture - ) -> None: - """Verifies that get_data_sources prints the expected table output. - - Args: - session (Session): Database session. - capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. - """ - - # AimbatSeismogram has external_id of datasource and event: - statement = ( - select(AimbatDataSource.id) - .join(AimbatSeismogram) - .join(AimbatEvent) - .where(AimbatEvent.active == 1) - ) - expected_ids = session.exec(statement).all() - - print_data_table(session, short=False, all_events=False) - - captured = capsys.readouterr() - assert "Data sources for event" in captured.out - for id in expected_ids: - assert ( - str(id) in captured.out - ), "expected data source ID to be in the output table" - - def test_print_data_table_for_active_event_short( - self, session: Session, capsys: pytest.CaptureFixture - ) -> None: - """Verifies that get_data_sources prints the expected table output. - - Args: - session (Session): Database session. - capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. - """ - - # AimbatSeismogram has external_id of datasource and event: - statement = ( - select(AimbatDataSource.id) - .join(AimbatSeismogram) - .join(AimbatEvent) - .where(AimbatEvent.active == 1) - ) - expected_ids = session.exec(statement).all() - - print_data_table(session, short=True, all_events=False) - - captured = capsys.readouterr() - assert "Data sources for event" in captured.out - for id in expected_ids: - assert ( - str(id)[:2] in captured.out - ), "expected data source ID to be in the output table" diff --git a/tests/integration/test_uuid.py b/tests/integration/utils/test_uuid.py similarity index 100% rename from tests/integration/test_uuid.py rename to tests/integration/utils/test_uuid.py diff --git a/tests/unit/cli/test_common.py b/tests/unit/_cli/test_common.py similarity index 98% rename from tests/unit/cli/test_common.py rename to tests/unit/_cli/test_common.py index ec390384..0ec166e3 100644 --- a/tests/unit/cli/test_common.py +++ b/tests/unit/_cli/test_common.py @@ -1,7 +1,7 @@ -"""Unit tests for aimbat.cli._common.""" +"""Unit tests for aimbat._cli.common.""" import pytest -from aimbat.cli._common import ( +from aimbat._cli.common import ( GlobalParameters, PlotParameters, IccsPlotParameters, diff --git a/tests/unit/aimbat_types/test_pydantic.py b/tests/unit/_types/test_pydantic.py similarity index 97% rename from tests/unit/aimbat_types/test_pydantic.py rename to tests/unit/_types/test_pydantic.py index 215f7ca3..ae60d044 100644 --- a/tests/unit/aimbat_types/test_pydantic.py +++ b/tests/unit/_types/test_pydantic.py @@ -1,8 +1,8 @@ -"""Tests for aimbat_types._pydantic custom Pydantic types.""" +"""Tests for aimbat._types._pydantic custom Pydantic types.""" import pytest from pydantic import BaseModel, ValidationError -from aimbat.aimbat_types import ( +from aimbat._types import ( PydanticTimestamp, PydanticTimedelta, PydanticNegativeTimedelta, diff --git a/tests/unit/aimbat_types/test_sqlalchemy.py b/tests/unit/_types/test_sqlalchemy.py similarity index 98% rename from tests/unit/aimbat_types/test_sqlalchemy.py rename to tests/unit/_types/test_sqlalchemy.py index bb16deea..50baf0d0 100644 --- a/tests/unit/aimbat_types/test_sqlalchemy.py +++ b/tests/unit/_types/test_sqlalchemy.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from unittest.mock import MagicMock from sqlalchemy.engine import Dialect -from aimbat.aimbat_types import SAPandasTimestamp, SAPandasTimedelta +from aimbat._types import SAPandasTimestamp, SAPandasTimedelta @pytest.fixture diff --git a/tests/unit/io/test_json_sources.py b/tests/unit/io/test_json_sources.py new file mode 100644 index 00000000..0c71e9d1 --- /dev/null +++ b/tests/unit/io/test_json_sources.py @@ -0,0 +1,168 @@ +"""Unit tests for aimbat.io.json.""" + +import json +import pytest +from pathlib import Path +from pandas import Timestamp +from pydantic import ValidationError +from aimbat.io.json import create_event_from_json, create_station_from_json +from aimbat.models import AimbatEvent, AimbatStation + +_STATION_DATA: dict = { + "name": "ANMO", + "network": "IU", + "location": "00", + "channel": "BHZ", + "latitude": 34.9459, + "longitude": -106.4572, + "elevation": 1820.0, +} + +_EVENT_DATA: dict = { + "time": "2020-01-01T00:00:00Z", + "latitude": 35.0, + "longitude": -120.0, + "depth": 10.0, +} + + +@pytest.fixture() +def station_json(tmp_path: Path) -> Path: + """Path to a temporary JSON file containing a station record. + + Args: + tmp_path: The pytest tmp_path fixture. + + Returns: + Path to the JSON station file. + """ + path = tmp_path / "station.json" + path.write_text(json.dumps(_STATION_DATA)) + return path + + +@pytest.fixture() +def event_json(tmp_path: Path) -> Path: + """Path to a temporary JSON file containing an event record. + + Args: + tmp_path: The pytest tmp_path fixture. + + Returns: + Path to the JSON event file. + """ + path = tmp_path / "event.json" + path.write_text(json.dumps(_EVENT_DATA)) + return path + + +# =================================================================== +# create_station_from_json +# =================================================================== + + +class TestCreateStationFromJson: + """Tests for create_station_from_json.""" + + def test_returns_aimbat_station(self, station_json: Path) -> None: + """Verifies that the function returns an AimbatStation instance. + + Args: + station_json: Path to a valid JSON station file. + """ + station = create_station_from_json(station_json) + assert isinstance(station, AimbatStation) + + def test_fields_match_json(self, station_json: Path) -> None: + """Verifies that station fields match the JSON values. + + Args: + station_json: Path to a valid JSON station file. + """ + station = create_station_from_json(station_json) + assert station.name == _STATION_DATA["name"] + assert station.network == _STATION_DATA["network"] + assert station.location == _STATION_DATA["location"] + assert station.channel == _STATION_DATA["channel"] + assert station.latitude == _STATION_DATA["latitude"] + assert station.longitude == _STATION_DATA["longitude"] + assert station.elevation == _STATION_DATA["elevation"] + + def test_missing_required_field_raises(self, tmp_path: Path) -> None: + """Verifies that a JSON file missing required fields raises ValidationError. + + Args: + tmp_path: Temporary directory path. + """ + path = tmp_path / "bad_station.json" + path.write_text(json.dumps({"name": "ANMO"})) + with pytest.raises(ValidationError): + create_station_from_json(path) + + def test_nonexistent_file_raises(self, tmp_path: Path) -> None: + """Verifies that reading from a non-existent file raises FileNotFoundError. + + Args: + tmp_path: Temporary directory path. + """ + with pytest.raises(FileNotFoundError): + create_station_from_json(tmp_path / "missing.json") + + +# =================================================================== +# create_event_from_json +# =================================================================== + + +class TestCreateEventFromJson: + """Tests for create_event_from_json.""" + + def test_returns_aimbat_event(self, event_json: Path) -> None: + """Verifies that the function returns an AimbatEvent instance. + + Args: + event_json: Path to a valid JSON event file. + """ + event = create_event_from_json(event_json) + assert isinstance(event, AimbatEvent) + + def test_fields_match_json(self, event_json: Path) -> None: + """Verifies that event fields match the JSON values. + + Args: + event_json: Path to a valid JSON event file. + """ + event = create_event_from_json(event_json) + assert event.time == Timestamp("2020-01-01T00:00:00Z") + assert event.latitude == _EVENT_DATA["latitude"] + assert event.longitude == _EVENT_DATA["longitude"] + assert event.depth == _EVENT_DATA["depth"] + + def test_has_parameters(self, event_json: Path) -> None: + """Verifies that the created event has initialised parameters. + + Args: + event_json: Path to a valid JSON event file. + """ + event = create_event_from_json(event_json) + assert event.parameters is not None + + def test_missing_required_field_raises(self, tmp_path: Path) -> None: + """Verifies that a JSON file missing required fields raises ValidationError. + + Args: + tmp_path: Temporary directory path. + """ + path = tmp_path / "bad_event.json" + path.write_text(json.dumps({"latitude": 35.0})) + with pytest.raises(ValidationError): + create_event_from_json(path) + + def test_nonexistent_file_raises(self, tmp_path: Path) -> None: + """Verifies that reading from a non-existent file raises FileNotFoundError. + + Args: + tmp_path: Temporary directory path. + """ + with pytest.raises(FileNotFoundError): + create_event_from_json(tmp_path / "missing.json") diff --git a/tests/unit/io/test_sac.py b/tests/unit/io/test_sac.py index 5ed1203d..69b4c52d 100644 --- a/tests/unit/io/test_sac.py +++ b/tests/unit/io/test_sac.py @@ -1,6 +1,6 @@ """Unit tests for aimbat.io._sac.""" -from aimbat.io._sac import ( +from aimbat.io.sac import ( create_event_from_sacfile, create_seismogram_from_sacfile_and_pick_header, create_station_from_sacfile, diff --git a/zensical.toml b/zensical.toml index 29f81c1c..de95e485 100644 --- a/zensical.toml +++ b/zensical.toml @@ -4,13 +4,14 @@ site_description = "Aimbat documentation." site_author = "Simon Lloyd" site_url = "https://aimbat.pysmo.org/" copyright = """ -Copyright © 2012 - 2026 Simon Lloyd +Copyright © 2012 - present AIMBAT contributors. """ repo_name = "pysmo/aimbat" repo_url = "https://github.com/pysmo/aimbat" nav = [ { "Home" = "index.md" }, { "First steps" = [ + "first-steps/core-concepts.md", {"Installation" = "first-steps/installation.md"}, {"Data & Conventions" = "first-steps/data.md"}, {"Workflow & Strategy" = "first-steps/workflow.md"}, @@ -24,8 +25,8 @@ nav = [ { "API reference" = [ "api/aimbat.md", "api/aimbat/app.md", - "api/aimbat/aimbat_types.md", - "api/aimbat/cli.md", + "api/aimbat/_types.md", + "api/aimbat/_cli.md", "api/aimbat/core.md", "api/aimbat/db.md", "api/aimbat/io.md",