diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2936649..f803996 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,7 +3,6 @@ ## Validation -- [ ] `python tools/generate_docs.py --check` - [ ] `python -m unittest discover -s tests -v` - [ ] `python tools/validate_specs.py` - [ ] `python -m compileall tools` @@ -12,6 +11,6 @@ ## Reviewer Checklist - [ ] JSON specifications and conformance cases remain the normative source of truth. -- [ ] Scientific notes, if changed, are explanatory and do not duplicate full input/output or parameter definitions. -- [ ] Specification and scientific-note changes were reviewed for consistency. +- [ ] Method pages help users call and interpret a function without duplicating the normative contract. +- [ ] Specification, method-page, field-description, reference, and case changes were reviewed for consistency. - [ ] No implementation repository changes are included unless this PR explicitly targets one. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8550c9b..12147c4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,9 +28,6 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements-dev.txt - - name: Check generated documentation - run: python tools/generate_docs.py --check - - name: Build documentation run: mkdocs build --strict diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1b89cb..9aea18e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,9 +105,6 @@ jobs: - name: Validate Biosiglib run: python tools/validate_specs.py - - name: Check generated documentation - run: python tools/generate_docs.py --check - - name: Build documentation run: mkdocs build --strict diff --git a/AGENTS.md b/AGENTS.md index fae0230..0e48cdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,92 +1,17 @@ # AGENTS -This file provides persistent context for AI coding agents working in Biosiglib. Treat the decisions below as project policy unless the maintainers explicitly change them. - -1. Biosiglib is a multimodal biomedical signal-processing ecosystem supporting ECG, PPG, respiration, HRV, and general signal-processing tools. HRV is one module, not the central purpose of the whole project. - -2. Biosiglib is the language-independent source of truth for: -- public algorithm behavior; -- inputs and outputs; -- units; -- parameters and defaults; -- numerical definitions; -- missing-value and edge-case behavior; -- scientific provenance; -- shared conformance cases. - -3. Biosigmat and Biosigpy are independent implementations. Biosigpy is not a line-by-line translation of Biosigmat. - -4. Implementations may use idiomatic language-specific APIs, internal architectures, data structures, exceptions, and plotting tools. They must preserve the normative scientific and computational behavior defined by Biosiglib. - -Canonical Biosiglib IDs use snake_case. Python should generally use the canonical names directly. MATLAB may use idiomatic camelCase or name-value spelling, but conceptual mappings must be explicit and aligned. -Normative Biosiglib specification identifiers, including input IDs, output IDs, parameter IDs, definition targets, and other canonical structured IDs, must use snake_case. Implementation-specific naming conventions such as MATLAB camelCase must not appear as normative Biosiglib identifiers. - -ECG R-wave timing identifiers must use `r_wave_*`, not `r_peak_*`. Use R-wave terminology in public Biosiglib contracts because a chosen fiducial point may not be a literal amplitude peak. - -Generic timing or interval algorithms, including `hrv.tdmetrics`, must remain modality-generic unless the contract explicitly narrows them. Describe `dtk`-style inputs as cleaned beat-to-beat or pulse-to-pulse intervals rather than ECG- or R-wave-specific timing. - -Shared conformance cases should focus on positive outputs, algorithmically meaningful edge behavior, and cross-language semantic decisions. Avoid adding separate shared cases for every trivial argument-validation variant when the specification already states the generic type, shape, or scalar-value constraint. -Do not create shared fixtures or conformance cases solely for trivial degenerate behavior such as empty inputs, empty outputs, all-NaN inputs, or all-NaN outputs when the behavior can be stated unambiguously in the specification. Define those behaviors in the normative specification text instead, unless the case exercises algorithmically meaningful processing, cross-language ambiguity, or a regression-prone semantic decision. -Conformance case filenames and case IDs should use descriptive names without serial suffixes such as `_001`. Add a serial suffix only when multiple genuinely distinct cases would otherwise need the same descriptive filename or case ID. - -5. Specifications will use JSON and will be validated using JSON Schema. Human-readable web documentation will be generated from the JSON specifications. Generated documentation is not the normative source. - -6. Specification fields will distinguish normative information from informative documentation. - -7. Shared numerical and signal data will use: -- JSON for metadata and small structured values; -- CSV for signals, annotations, and tabular numerical data. - -8. Shared fixtures will initially remain in Biosiglib. Their catalog must contain machine-readable metadata required by tests and examples, including modality, device, sampling frequency, units, processing, duration, and annotations. - -9. Conformance comparisons currently use: -- absolute numerical tolerances; -- explicit comparison of NaN values; -- language-independent expected-error categories for invalid type, shape, value, and insufficient-data behavior. -Relative tolerances are not part of the initial design. - -10. Reference outputs may pragmatically be generated by the mature Biosigmat implementation, especially for complex algorithms. Their provenance must be recorded. Biosigmat is not automatically correct when a disagreement is detected; each disagreement must be analysed individually. - -11. Do not change scientific or computational behavior without explicit maintainer review. This includes filtering direction and phase behavior, NaN handling, default filters, default parameters, units, physiological interpretation, and reference-result provenance. Do not escalate purely idiomatic differences unless they affect scientific behavior. Examples: zero-based versus one-based internal indexing, exception class names, plotting library choices, or local variable names normally do not require maintainer review. - -12. Scientific algorithm authorship is recognised through citations to the original publications and relevant method extensions. Biosiglib software authorship belongs to the project maintainers. Do not introduce CRediT roles or intermediate per-algorithm software authorship systems. - -13. All repositories use GPL-3.0. - -14. Biosiglib, Biosigmat, and Biosigpy use independent semantic versioning with MAJOR.MINOR.PATCH. - -15. Workflows and examples are not public API contracts. However, corresponding workflows and examples across implementations should preserve the same conceptual processing sequence, parameters, input data, expected results, and scientific interpretation whenever possible. - -16. In Biosigmat: -- public functions under src/ require specifications; -- functions inside private/ do not; -- examples and workflows remain under examples/; -- all current public functions are considered stable. - -17. The generated specification catalog is the authoritative inventory. Do not maintain manual subsets of current specification IDs in policy or overview documents. - -18. For hrv.tdmetrics: -- the canonical input dtk is the cleaned beat-to-beat or pulse-to-pulse interval sequence in seconds; -- valid finite intervals must be strictly positive; Inf, -Inf, zero, and negative intervals are invalid; -- NaN values in dtk are allowed as missing or invalid interval markers and are omitted from metric calculations; -- dtk is produced before tdmetrics by beat or pulse detection, interval construction, and preprocessing for artifacts, missed beats, false detections, ectopic beats, outliers, and missing data; -- mean heart or pulse rate is defined as 60 / mean(valid dtk), after omitting NaN markers; -- outputs use their conventional units. - -19. Avoid overengineering. Do not introduce resource APIs, cross-language test runners, code generators, databases, or additional repositories unless they solve a demonstrated problem. - -20. Use English for filenames, code, comments, JSON field names, and technical documentation. - -21. Do not make architectural decisions that contradict this file without explicitly reporting the conflict to the maintainers. - -22. All local Python tooling in Biosiglib must run inside the repository-local `.venv`. AI agents must create `.venv` when it is absent, invoke the `.venv` Python executable explicitly, and never commit `.venv` or generated Python caches. `requirements-dev.txt` remains the dependency declaration for Biosiglib tooling. CI environments, when introduced later, must install dependencies in a clean environment and must not reuse the local `.venv`. Biosigpy will also use its own independent repository-local `.venv` when its package structure is created. - -23. Each implementation repository contains a `biosiglib.lock` file with one exact lowercase Biosiglib commit SHA. Code merged into an implementation must conform to every specification in that commit and execute every shared case; partial support and implementation roadmaps belong in issues and pull requests. Implementation versions remain independent from Biosiglib versions, and no per-algorithm version is used. - -24. Each implementation's normal full test suite must validate the `biosiglib.lock` format, verify the resolved checkout commit, and execute every shared case. Biosiglib does not define or validate downstream implementation metadata. - -25. Scientific notes under `docs/scientific/` are explanatory, not normative. They help researchers understand a method's purpose, rationale, assumptions, interpretation, and limitations, but they must not duplicate or replace the JSON specification. - -26. JSON specifications and conformance cases remain the source of truth for algorithm behavior, inputs, outputs, units, parameters, defaults, numerical definitions, missing-value behavior, edge cases, tolerances, and implementation conformance. - -27. Scientific notes must declare a `spec_id` in Markdown front matter, link to the corresponding normative specification, and avoid copying full input/output or parameter contracts from JSON specs. When a spec or note changes, AI agents and reviewers must check consistency between the explanatory note, the JSON specification, and relevant conformance cases. +Persistent project rules for coding agents working in Biosiglib: + +1. Biosiglib is the language-independent contract for public methods implemented independently by Biosigpy and Biosigmat. JSON specifications and shared conformance cases are normative. +2. A downstream `biosiglib.lock` declares total conformance with one exact commit. Partial support belongs in issues or pull requests, not repository metadata. +3. Preserve normative scientific behavior across languages while allowing idiomatic APIs, internal structures, exceptions, indexing, and plotting. +4. Do not change formulas, filtering direction or phase, units, defaults, NaN behavior, physiological meaning, edge cases, or reference results without explicit maintainer review. +5. Canonical structured IDs use `snake_case`. ECG timing uses `r_wave_*`, not `r_peak_*`. Keep generic interval methods modality-neutral unless their contract says otherwise. +6. Add shared cases for meaningful numerical behavior, cross-language ambiguity, and regressions. Do not multiply cases for trivial validation already stated unambiguously in a specification. +7. Use JSON for structured metadata and small values, and CSV for signals, annotations, and tabular numerical data. Conformance comparisons use absolute tolerances and explicit NaN comparison. +8. Every public method has one page under `docs/methods/`. It should help a user choose, call, and interpret the method. Keep implementation history and development-process commentary out of public documentation. +9. Method interfaces, references, and technical links are injected during the MkDocs build. Do not commit derived Markdown or duplicate the normative contract in prose. +10. Keep specifications, method pages, field descriptions, references, and cases consistent. Scientific authorship is recognised through original publications; software authorship belongs to the project maintainers. +11. Use English for filenames, code, comments, structured fields, and technical documentation. All repositories use GPL-3.0 and independent semantic versioning. +12. Use the repository-local `.venv` and the commands in `CONTRIBUTING.md`. Do not commit virtual environments or caches. +13. Avoid new generators, resource APIs, databases, cross-language runners, or repositories unless they solve a demonstrated problem. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..90187d0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing to Biosiglib + +## Local setup + +Create a repository-local virtual environment and install the development dependencies: + +```powershell +py -m venv .venv +.venv\Scripts\python.exe -m pip install -r requirements-dev.txt +``` + +## Validation + +Run these checks before submitting a change: + +```powershell +.venv\Scripts\python.exe -m compileall tools +.venv\Scripts\python.exe -m unittest discover -s tests -v +.venv\Scripts\python.exe tools\validate_specs.py +.venv\Scripts\python.exe -m mkdocs build --strict +git diff --check +``` + +## Method changes + +The JSON specification and shared validation cases define behavior. A method page explains its purpose, expected data, scientific rationale, interpretation, and limitations. Repeated interface tables, references, and technical links are inserted from machine-readable sources during the documentation build. + +When adding or changing a method: + +1. update its specification and meaningful validation cases; +2. update the corresponding page and field descriptions under `docs/methods/`; +3. keep scientific references in `references/references.json`; +4. run the full validation set above. + +Do not include implementation history, compatibility commentary, release planning, or contributor workflow in public method pages. Link to source code or technical artifacts when their contents do not need to be restated for a user. diff --git a/README.md b/README.md index 64ebdc6..7f115f8 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,26 @@ # Biosiglib -**Language-independent specifications and shared validation resources for reproducible biomedical signal processing.** +Biosiglib is the shared, language-independent description of biomedical signal-processing methods implemented by BSICoS. -Biosiglib defines the expected scientific and computational behavior of public algorithms implemented by the BSICoS biomedical signal-processing libraries. It is not an executable signal-processing package; it contains specifications, scientific provenance, shared fixtures, conformance cases, and documentation-generation tooling. +It contains: -## Ecosystem +- practical and scientific method documentation; +- machine-readable JSON contracts; +- scientific references; +- shared validation cases and fixtures. -- [Biosigmat](https://github.com/BSICoS/biosigmat): MATLAB implementation. -- [Biosigpy](https://github.com/BSICoS/biosigpy): Python implementation. - -## What is in this repository - -- Machine-readable JSON specifications for public algorithms. -- JSON Schemas used to validate specifications and implementation manifests. -- Scientific references associated with each method. -- Shared fixtures and conformance cases. -- Tools for validation and generated documentation. +Biosiglib is not an executable package. Use [Biosigpy](https://github.com/BSICoS/biosigpy) for Python or [Biosigmat](https://github.com/BSICoS/biosigmat) for MATLAB. ## Documentation -The generated documentation site is available at [https://bsicos.github.io/biosiglib/](https://bsicos.github.io/biosiglib/). - -The website is generated from the JSON specifications. The JSON files remain the normative source of truth for algorithm behavior, inputs, outputs, units, defaults, missing-value handling, edge cases, and tolerances. Implementations declare total conformance with one exact Biosiglib commit. - -## Local validation - -See [docs/development.md](docs/development.md) for local setup and validation commands. - -## Releases +Browse the methods at [bsicos.github.io/biosiglib](https://bsicos.github.io/biosiglib/). Each page explains the expected inputs and outputs, scientific basis, limitations, references, and links to both implementations. -See the [SemVer classification and release-readiness checklist](docs/releases.md) for release semantics and coordinated conformance details. +The JSON specifications and validation cases define the exact cross-language behavior. They are linked from each method page for contributors and advanced users. -## Project status +## Contributing -Biosiglib is under active development. The generated specification catalog is the authoritative inventory of its current algorithm contracts. +See [CONTRIBUTING.md](CONTRIBUTING.md) for the local setup, validation commands, and documentation rules. ## License -Biosiglib is distributed under the GNU General Public License version 3. See [LICENSE](LICENSE) for the complete license text. +Biosiglib is distributed under the [GNU General Public License version 3](LICENSE). diff --git a/docs/citation.md b/docs/citation.md index fac14fd..c49fb61 100644 --- a/docs/citation.md +++ b/docs/citation.md @@ -1,13 +1,11 @@ -# Citation +# Cite Biosiglib -Biosiglib will provide project-level software citation metadata in a future `CITATION.cff` file. A Zenodo DOI is also planned for archived software releases. +When reproducibility matters, cite the repository and the exact commit used by your analysis: -Until that metadata is added, cite the repository and release or commit used in your work as precisely as possible. +```text +BSICoS Biosiglib. Biomedical signal-processing method specifications and validation resources. https://github.com/BSICoS/biosiglib, commit . +``` -## Software And Scientific Citations +Also cite the original publication for each method you use. The relevant references are listed on its page in the [method catalog](methods/index.md). -Software citation and original scientific algorithm citation are complementary. - -Citing Biosiglib recognizes the software project: its specifications, validation resources, documentation, release process, and maintenance. Citing the original scientific publications recognizes the authorship of the algorithms and methodological foundations implemented by the ecosystem. - -For example, using an ECG detector through the Biosiglib ecosystem may require both the Biosiglib software citation and the original Pan-Tompkins method citation when that method is scientifically relevant to the work. +If you used an executable implementation, identify [Biosigpy or Biosigmat](implementations.md) and its version as well. diff --git a/docs/conformance.md b/docs/conformance.md deleted file mode 100644 index 1269ebe..0000000 --- a/docs/conformance.md +++ /dev/null @@ -1,31 +0,0 @@ -# Conformance - -Conformance describes how a language-specific implementation validates its relationship to Biosiglib. - -Each implementation repository contains a `biosiglib.lock` file with one exact, lowercase, 40-character Biosiglib commit SHA. The lock has no status, implementation metadata, or schema: its only purpose is to make the tested contract reproducible. - -Conformance is total: code merged into an implementation must conform to every specification in the pinned commit and must execute every shared conformance case. Partial support, roadmaps, and work in progress belong in issues and pull requests rather than in the lock. - -Passing the shared cases is executable evidence of conformance. It does not make the cases a substitute for the complete normative JSON contracts. - -## Exact Pinning - -A downstream lock pins one exact Biosiglib commit. A semantic version alone is not enough because conformance must be reproducible against the precise specifications, schemas, fixtures, and conformance cases used during validation. - -Implementation and Biosiglib release versions remain visible in their respective repositories and release notes. They are not duplicated in the lock. - -## Validation Across Implementations - -Biosigmat and Biosigpy validate their lock and behavior as part of their normal full test suite. Each implementation can keep its own public API style, internal architecture, error classes, and plotting tools, but its normative outputs and edge-case behavior must match every contract in the pinned commit within the declared tolerances. - -The Biosiglib validator checks only Biosiglib's own schemas, specifications, fixtures, and cases: - -```bash -python tools/validate_specs.py -``` - -Cross-language conformance is built from shared specifications, shared fixtures, and shared expected results rather than from one implementation copying the other. - -When behavior depends on requesting an optional output, a conformance case uses `requested_outputs` to declare the return profile that must be exercised. This is especially important for expected-error cases, where there are no expected-output mappings from which to infer the request. - -Successful cases may also use `expected_warnings` to require observable, non-fatal diagnostics. Warning identifiers are defined by the corresponding specification. Each expected warning lists the complete `affected_ids` set that must be aggregated into that single warning. Warning and affected-id ordering is not significant. If `expected_warnings` is absent, the call must not emit a normative warning. Expected-error cases cannot also declare warnings because the operation does not complete successfully. diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index 27319f8..0000000 --- a/docs/development.md +++ /dev/null @@ -1,82 +0,0 @@ -# Development - -Development in Biosiglib should keep the machine-readable sources and the human-readable documentation aligned. - -## Local Setup - -Create a repository-local virtual environment and install the development dependencies before running the validation tools. - -Windows PowerShell: - -```powershell -py -m venv .venv -.venv\Scripts\python.exe -m pip install --upgrade pip -.venv\Scripts\python.exe -m pip install -r requirements-dev.txt -``` - -Linux/macOS: - -```bash -python3 -m venv .venv -.venv/bin/python -m pip install --upgrade pip -.venv/bin/python -m pip install -r requirements-dev.txt -``` - -## Local Validation - -Use the repository-local `.venv` for local Python tooling. After creating and installing the development environment, the core validation commands are: - -```bash -python tools/generate_docs.py -python tools/generate_docs.py --check -python -m unittest discover -s tests -v -python tools/validate_specs.py -python -m compileall tools -mkdocs build --strict -``` - -On Windows PowerShell, explicit `.venv` invocations look like: - -```powershell -.venv\Scripts\python.exe tools\generate_docs.py -.venv\Scripts\python.exe tools\generate_docs.py --check -.venv\Scripts\python.exe -m unittest discover -s tests -v -.venv\Scripts\python.exe tools\validate_specs.py -.venv\Scripts\python.exe -m compileall tools -.venv\Scripts\python.exe -m mkdocs build --strict -``` - -`mkdocs build --strict` treats warnings as build failures, which keeps broken links and configuration drift visible during review. - -Implementation repositories validate their `biosiglib.lock` and all shared cases through their own normal full test suite. Biosiglib does not interpret downstream metadata. - -## Documentation Workflow - -The documentation site is built on pull requests. On pushes to `main`, the workflow also prepares a GitHub Pages deployment artifact and runs the Pages deployment action. - -If the repository has not yet enabled Pages publication, maintainers may need to configure GitHub Pages in repository settings and select "GitHub Actions" as the source. - -## Generated Documentation - -Specification pages under `docs/generated/specifications/` are generated from the JSON specifications. Generated files must be committed and kept in sync with the JSON source. - -Run `python tools/generate_docs.py` after changing any file under `specs/*/*/spec.json` or conformance cases linked from a specification page. CI runs `python tools/generate_docs.py --check` and fails when generated pages are missing or stale. - -## Scientific Notes - -Scientific notes under `docs/scientific/` are short explanatory pages for researchers. They describe why a method is useful, the scientific rationale behind it, key assumptions, and interpretation limits. - -They are not normative. JSON specifications and conformance cases remain the source of truth for inputs, outputs, units, parameters, defaults, numerical definitions, edge-case behavior, tolerances, and implementation conformance. - -Scientific notes must declare `spec_id` in Markdown front matter and link to the corresponding generated specification page. They should summarize the method in readable language without duplicating the full input, parameter, output, tolerance, or edge-case definitions from the JSON specification. - -## Spec And Note Review Checklist - -For pull requests touching `specs/`, `conformance/`, or `docs/scientific/`, reviewers should check: - -* JSON specs and conformance cases still define the normative contract; -* scientific notes remain explanatory and do not introduce independent requirements; -* note `spec_id` values point to existing specifications; -* notes are discoverable from the scientific documentation section or MkDocs navigation; -* related specs, notes, generated docs, references, fixtures, and conformance cases remain consistent; -* validation and documentation build commands are reported with their results. diff --git a/docs/ecosystem.md b/docs/ecosystem.md deleted file mode 100644 index 9af8202..0000000 --- a/docs/ecosystem.md +++ /dev/null @@ -1,27 +0,0 @@ -# Ecosystem - -Biosiglib coordinates a small ecosystem of repositories with separate responsibilities. - -| Repository | Role | -| --- | --- | -| [Biosiglib](https://github.com/BSICoS/biosiglib) | Source of truth for language-independent specifications, shared fixtures, conformance cases, validation tools, and coordinated release policy. | -| [Biosigmat](https://github.com/BSICoS/biosigmat) | MATLAB implementation of the Biosiglib specifications. | -| [Biosigpy](https://github.com/BSICoS/biosigpy) | Python implementation of the Biosiglib specifications. | - -Biosigmat and Biosigpy may expose idiomatic language-specific APIs. They do not need identical internal architecture, but they must preserve the normative behavior defined by Biosiglib. - -## Conformance and Releases - -Each implementation declares conformance with one exact Biosiglib commit. The declaration covers every specification in that commit; support is not selected algorithm by algorithm. - -The release path is: - -1. Prepare and validate the Biosiglib contract commit. -2. Adapt Biosigmat and Biosigpy to that exact commit and merge both implementations after their complete suites pass. -3. Release Biosiglib only after both downstream manifests pin the release target commit. - -The implementations remain independently versioned. Their one-line `biosiglib.lock` files record the reproducible commit relationship instead of mirroring the Biosiglib version number. - -## Source Of Truth - -When behavior is unclear, Biosiglib is the place to resolve it. Existing implementation behavior can inform a specification, especially when mature code already exists, but no implementation is automatically the authority. Disagreements should be analyzed against the Biosiglib specification, fixtures, conformance cases, and scientific references. diff --git a/docs/generated/specifications/ecg.baselineremove.md b/docs/generated/specifications/ecg.baselineremove.md deleted file mode 100644 index baf320a..0000000 --- a/docs/generated/specifications/ecg.baselineremove.md +++ /dev/null @@ -1,109 +0,0 @@ -# ECG baseline removal from fiducial isoelectric samples - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `ecg.baselineremove` | -| Module | `ecg` | -| Source JSON | [specs/ecg/baselineremove/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/ecg/baselineremove/spec.json) | - -## Summary - -Estimates a slowly varying ECG baseline from local means around fiducial positions and subtracts its spline interpolation. - -The method samples an isoelectric ECG level around supplied fiducial positions, interpolates those levels across the complete signal, and returns both the detrended ECG and estimated baseline. - -## Keywords - -`ECG`, `baseline wander`, `isoelectric level`, `fiducial positions`, `spline interpolation` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `meyer_keiser_ecg_baseline_spline_1977` | original_method | Supports estimating ECG baseline noise from PR-segment samples and interpolating those estimates with cubic splines; the exact compatibility rules in this specification are not attributed to the paper. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `ecg` | real_vector | vector | a.u. | false | false | minimum_length=1 | -| `fiducial_positions` | real_vector | vector | sample | false | false | exclusive_minimum=0, minimum_length=1 | -| `offset` | integer_scalar | scalar | sample | false | false | minimum=0 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `window_size` | integer_scalar | 5 | sample | exclusive_minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `ecg_detrended` | real_vector | vector | a.u. | -| `baseline` | real_vector | vector | a.u. | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `canonical_sample_grid` | Interpret ecg on the one-based integer sample grid 1 through N, where N = length(ecg). Public APIs may accept native indices only if they convert them to this grid before applying the remaining rules. | | -| `adjusted_positions` | Subtract offset from every fiducial_positions value, then round each result to the nearest integer with exact half-way values rounded away from zero. Sort the rounded results, remove duplicates, and discard values outside the inclusive canonical range 1 through N, in that order. The remaining values are the valid fiducial positions. | | -| `fiducial_ordering` | Raw fiducial_positions may be unordered, repeated, and fractional. Their order and multiplicity do not affect the result after adjusted-position sorting and deduplication. | | -| `local_window` | Set radius = floor(window_size / 2). For each valid fiducial position p, use every ECG sample from max(1, p - radius) through min(N, p + radius), inclusive. The nominal span is therefore 2 * radius + 1: an odd window_size uses exactly window_size samples away from boundaries, while an even window_size uses window_size + 1 samples. Truncate the span at signal boundaries without padding. | | -| `fiducial_levels` | At each valid fiducial position, compute the arithmetic mean of all ECG samples in its local_window. Pair the resulting finite level with that valid one-based position. | | -| `spline_interpolation` | Interpolate the fiducial levels over every integer position 1 through N using the same polynomial piecewise model as MATLAB spline with same-size position and value vectors: two valid positions define a linear polynomial, three define a quadratic polynomial, and four or more define a cubic not-a-knot spline. Evaluate outside the first and last valid positions by polynomial extrapolation from the corresponding end piece. | | -| `baseline` | Return the interpolated or extrapolated fiducial-level model evaluated at every canonical ECG sample position. baseline has length N and is aligned with ecg. | | -| `ecg_detrended` | Return ecg - baseline element by element. ecg_detrended has length N and is aligned with ecg. | | -| `comparison` | Each conformance output defines an absolute tolerance and uses zero relative tolerance. Absence of expected_warnings means that no warning is expected. | | -| `error_categories` | Use invalid_type for non-real or non-numeric inputs, invalid_shape for non-vector ECG or fiducial inputs and non-scalar offset or window_size, invalid_value for empty or nonfinite vectors, nonpositive raw fiducial positions, negative or non-integer offset, or nonpositive or non-integer window_size, and insufficient_data when exactly one valid fiducial position remains. Language-specific exception and warning classes and message text are not normative. | | - -## Warnings - -| id | condition | effect | aggregation | -| --- | --- | --- | --- | -| `no_valid_fiducial_positions` | No fiducial position remains after offset subtraction, half-away-from-zero rounding, sorting, deduplication, and range filtering. | Return ecg unchanged as ecg_detrended and an all-zero baseline of the same length. | Emit exactly once per call and identify fiducial_positions as the complete affected-id set. | - -## Behavior - -### Nan handling - -NaN, positive infinity, negative infinity, and complex values in ecg or fiducial_positions are invalid. Successful outputs are finite for finite inputs. - -### Empty input - -Empty ecg and empty fiducial_positions inputs are invalid. - -### Input orientation - -Treat ecg and fiducial_positions as one-dimensional vectors regardless of MATLAB row or column orientation. Both outputs are one-dimensional ordered vectors aligned with ecg; a language may preserve the ECG vector orientation in its direct API. - -### Insufficient data - -If no valid fiducial position remains, emit no_valid_fiducial_positions and return the defined identity result. If exactly one valid position remains, raise insufficient_data because a spline baseline cannot be defined. Two or more valid positions are sufficient. - -## Informative Notes - -* Using PR-segment fiducials and cubic-spline interpolation is literature-backed; offset, local-window, boundary, and fallback details are empirical Biosigmat compatibility choices. -* The canonical sample-position grid is one-based even when a public implementation exposes native zero-based indices. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `ecg.baselineremove.boundary_truncated_local_means` | [conformance/ecg/baselineremove/boundary_truncated_local_means.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/boundary_truncated_local_means.json) | -| `ecg.baselineremove.empty_ecg_error` | [conformance/ecg/baselineremove/empty_ecg_error.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/empty_ecg_error.json) | -| `ecg.baselineremove.even_window_linear_extrapolation` | [conformance/ecg/baselineremove/even_window_linear_extrapolation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/even_window_linear_extrapolation.json) | -| `ecg.baselineremove.fractional_positions_quadratic_extrapolation` | [conformance/ecg/baselineremove/fractional_positions_quadratic_extrapolation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/fractional_positions_quadratic_extrapolation.json) | -| `ecg.baselineremove.invalid_ecg_matrix` | [conformance/ecg/baselineremove/invalid_ecg_matrix.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/invalid_ecg_matrix.json) | -| `ecg.baselineremove.invalid_ecg_non_numeric` | [conformance/ecg/baselineremove/invalid_ecg_non_numeric.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/invalid_ecg_non_numeric.json) | -| `ecg.baselineremove.no_valid_fiducials_identity_warning` | [conformance/ecg/baselineremove/no_valid_fiducials_identity_warning.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/no_valid_fiducials_identity_warning.json) | -| `ecg.baselineremove.nonfinite_ecg_error` | [conformance/ecg/baselineremove/nonfinite_ecg_error.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/nonfinite_ecg_error.json) | -| `ecg.baselineremove.nonfinite_fiducial_error` | [conformance/ecg/baselineremove/nonfinite_fiducial_error.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/nonfinite_fiducial_error.json) | -| `ecg.baselineremove.not_a_knot_cubic_extrapolation` | [conformance/ecg/baselineremove/not_a_knot_cubic_extrapolation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/not_a_knot_cubic_extrapolation.json) | -| `ecg.baselineremove.single_valid_fiducial_error` | [conformance/ecg/baselineremove/single_valid_fiducial_error.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/baselineremove/single_valid_fiducial_error.json) | diff --git a/docs/generated/specifications/ecg.pantompkins.md b/docs/generated/specifications/ecg.pantompkins.md deleted file mode 100644 index 24ae712..0000000 --- a/docs/generated/specifications/ecg.pantompkins.md +++ /dev/null @@ -1,100 +0,0 @@ -# Pan-Tompkins-style ECG R-wave detection - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `ecg.pantompkins` | -| Module | `ecg` | -| Source JSON | [specs/ecg/pantompkins/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/ecg/pantompkins/spec.json) | - -## Summary - -Detects ordered R-wave occurrence times from a sampled ECG signal and exposes intermediate processing signals for plotting and debugging. - -This Pan-Tompkins-style detector is implemented in Biosigmat using bandpass filtering, derivative filtering, squaring, moving-window integration, peak detection, and peak refinement. The current implementation follows the Pan-Tompkins processing style but is not a byte-for-byte reproduction of the original paper. - -## Keywords - -`ECG`, `Pan-Tompkins`, `QRS detection`, `R waves`, `debugging`, `intermediate signals` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `pan_tompkins_1985` | original_method | Algorithm origin for the Pan-Tompkins-style processing chain. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `ecg` | real_vector | vector | a.u. | true | false | None | -| `sampling_frequency` | real_scalar | scalar | Hz | false | false | exclusive_minimum=0 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `bandpass_frequency` | real_vector | [5, 12] | Hz | minimum_length=2 | -| `integration_window_size` | real_scalar | 0.15 | s | exclusive_minimum=0 | -| `minimum_peak_distance` | real_scalar | 0.5 | s | exclusive_minimum=0 | -| `snap_to_peak_window_size` | real_scalar | 20 | sample | exclusive_minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `r_wave_times` | real_vector | vector | s | -| `ecg_filtered` | real_vector | vector | a.u. | -| `decg_squared` | real_vector | vector | a.u.^2 | -| `decg_envelope` | real_vector | vector | a.u.^2 | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `finite_ecg_segment` | A finite ECG segment is a maximal contiguous run of ecg samples that are neither NaN nor infinite. NaN samples are hard boundaries between finite ECG segments; Inf and -Inf samples are invalid inputs. | | -| `detection_chain` | Within each finite ECG segment, apply bandpass filtering, derivative filtering, squaring, moving-window integration, peak detection, and peak refinement without using samples across a NaN boundary. | | -| `r_wave_times` | Detected ECG R-wave occurrence times in seconds, sorted in ascending order. | | -| `ecg_filtered` | Bandpass-filtered ECG signal, represented as a one-dimensional vector with the same canonical sample order and length as the input ECG. | | -| `decg_squared` | Squared derivative-filtered ECG signal, represented as a one-dimensional vector with the same canonical sample order and length as the input ECG. | | -| `decg_envelope` | Squared and moving-window integrated detection envelope, represented as a one-dimensional vector with the same canonical sample order and length as the input ECG. | | - -## Behavior - -### Nan handling - -NaN samples in ecg are allowed and act as hard boundaries between finite ECG segments. Filtering, integration, peak detection, and peak refinement must not use samples across a NaN boundary. No R-wave detection is returned inside a NaN gap. Intermediate vector outputs remain aligned sample-by-sample with ecg and represent unprocessed NaN gaps as NaN. - -### Empty input - -Empty ECG input is invalid; the exact failure mechanism is implementation-specific. - -### Input orientation - -Treat ECG input as a one-dimensional vector regardless of row or column orientation. All vector outputs are conceptually one-dimensional ordered vectors. - -### Insufficient data - -An ECG signal with duration less than 3 seconds is insufficient data. Signal duration is defined as length(ecg) / sampling_frequency. A duration of exactly 3 seconds is sufficient. - -## Informative Notes - -* The primary detection target is the ECG R wave. -* Intermediate outputs are part of the public contract because they are used for plotting and debugging detections. -* Exact cross-language numerical equality of intermediate signals is not required by the first positive conformance case. -* ECG signals shorter than 3 seconds are insufficient data. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `ecg.pantompkins.invalid_ecg_matrix` | [conformance/ecg/pantompkins/invalid_ecg_matrix.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/pantompkins/invalid_ecg_matrix.json) | -| `ecg.pantompkins.invalid_ecg_non_numeric` | [conformance/ecg/pantompkins/invalid_ecg_non_numeric.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/pantompkins/invalid_ecg_non_numeric.json) | -| `ecg.pantompkins.invalid_sampling_frequency_non_numeric` | [conformance/ecg/pantompkins/invalid_sampling_frequency_non_numeric.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/pantompkins/invalid_sampling_frequency_non_numeric.json) | -| `ecg.pantompkins.invalid_sampling_frequency_non_positive` | [conformance/ecg/pantompkins/invalid_sampling_frequency_non_positive.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/pantompkins/invalid_sampling_frequency_non_positive.json) | -| `ecg.pantompkins.invalid_sampling_frequency_vector` | [conformance/ecg/pantompkins/invalid_sampling_frequency_vector.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/pantompkins/invalid_sampling_frequency_vector.json) | -| `ecg.pantompkins.medicom_mtd_r_wave_times` | [conformance/ecg/pantompkins/medicom_mtd_r_wave_times.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/pantompkins/medicom_mtd_r_wave_times.json) | diff --git a/docs/generated/specifications/ecg.sloperange.md b/docs/generated/specifications/ecg.sloperange.md deleted file mode 100644 index 7476e5c..0000000 --- a/docs/generated/specifications/ecg.sloperange.md +++ /dev/null @@ -1,98 +0,0 @@ -# Slope-range ECG-derived respiration - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `ecg.sloperange` | -| Module | `ecg` | -| Source JSON | [specs/ecg/sloperange/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/ecg/sloperange/spec.json) | - -## Summary - -Estimates an ECG-derived respiration amplitude series from derivative ECG morphology around detected R waves. - -The slope-range method summarizes beat-to-beat respiratory modulation by comparing the maximum upslope and minimum downslope of a derivative ECG signal in short windows around each R wave. - -## Keywords - -`ECG`, `ECG-derived respiration`, `EDR`, `slope range`, `respiratory modulation` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `kontaxis_edr_af_2020` | original_method | Primary method and provenance reference for slope-range ECG-derived respiration. | -| `varon_comparative_edr_2020` | validation | Comparative EDR context and validation evidence for single-lead ambulatory ECG. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `decg` | real_vector | vector | a.u. | false | false | minimum_length=2 | -| `r_wave_times` | real_vector | vector | s | false | false | minimum_length=1 | -| `sampling_frequency` | real_scalar | scalar | Hz | false | false | exclusive_minimum=0 | - -## Parameters - -No parameters. - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `edr` | real_vector | vector | a.u. | -| `upslopes` | real_vector | vector | a.u. | -| `downslopes` | real_vector | vector | a.u. | -| `upslope_max_positions` | real_vector | vector | sample | -| `downslope_min_positions` | real_vector | vector | sample | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `r_wave_times` | ECG R-wave occurrence times in seconds. Values must be finite, one-dimensional, strictly increasing, without repeats, and mappable onto the derivative ECG sample grid using sampling_frequency. | | -| `r_wave_samples` | Conceptual zero-based sample-grid positions computed as round(r_wave_times * sampling_frequency) on the derivative ECG sample grid. Each value must lie from 0 through length(decg) - 1, inclusive. Public implementations may retain native array indices in their direct APIs, but conformance values for normative position outputs must use this zero-based grid. | | -| `analysis_windows` | Set short_window = round(sampling_frequency * 0.015) and long_window = round(sampling_frequency * 0.05). The upslope_window contains integer offsets greater than -long_window and less than or equal to short_window. The downslope_window contains integer offsets greater than or equal to -short_window and less than long_window. | | -| `complete_beat` | A beat is complete only when both its upslope and downslope analysis windows lie entirely within the decg sample grid. Only complete beats contribute samples to upslopes or downslopes. | | -| `extrema_selection` | For each complete beat, select the maximum decg value in the upslope window and the minimum decg value in the downslope window. If multiple samples share the selected extreme value, choose the earliest sample in the corresponding window. | | -| `edr` | For each complete beat, compute edr as the decg value at upslope_max_positions minus the decg value at downslope_min_positions. Align edr with r_wave_times. | | -| `upslopes` | Return a vector with the same length, zero-based sample grid, and unit as decg. Copy decg inside the union of complete-beat upslope windows and set every other sample to NaN. | | -| `downslopes` | Return a vector with the same length, zero-based sample grid, and unit as decg. Copy decg inside the union of complete-beat downslope windows and set every other sample to NaN. | | -| `upslope_max_positions` | Return the selected upslope maximum positions on the conceptual zero-based decg sample grid, aligned with r_wave_times. Set the position to NaN for an incomplete beat. | | -| `downslope_min_positions` | Return the selected downslope minimum positions on the conceptual zero-based decg sample grid, aligned with r_wave_times. Set the position to NaN for an incomplete beat. | | -| `boundary_outputs` | For an incomplete beat, preserve alignment with r_wave_times and set the corresponding edr, upslope_max_positions, and downslope_min_positions values to NaN. Do not copy either incomplete beat window into upslopes or downslopes. | | - -## Behavior - -### Nan handling - -NaN and infinite values in decg, r_wave_times, or sampling_frequency are invalid inputs. NaN values mark incomplete beats in edr, upslope_max_positions, and downslope_min_positions, and samples outside complete-beat analysis windows in upslopes and downslopes. - -### Empty input - -Empty decg and empty r_wave_times inputs are invalid. - -### Input orientation - -Treat decg and r_wave_times as one-dimensional vectors regardless of row or column orientation. The edr, upslope_max_positions, and downslope_min_positions outputs are one-dimensional ordered vectors aligned with r_wave_times. The upslopes and downslopes outputs are one-dimensional ordered vectors aligned with decg. - -### Insufficient data - -If decg is too short to support both complete windows around a beat, the aligned edr and extrema-position values are NaN and that beat contributes no samples to either signal-aligned slope vector when the corresponding r_wave_samples value is inside the signal. R-wave times that map outside the derivative ECG sample grid are invalid. - -## Informative Notes - -* The signal-aligned slope vectors and selected extrema positions support visual inspection of the analysis performed around each R wave. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `ecg.sloperange.invalid_r_wave_time_out_of_bounds` | [conformance/ecg/sloperange/invalid_r_wave_time_out_of_bounds.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/sloperange/invalid_r_wave_time_out_of_bounds.json) | -| `ecg.sloperange.invalid_r_wave_times_not_strict` | [conformance/ecg/sloperange/invalid_r_wave_times_not_strict.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/sloperange/invalid_r_wave_times_not_strict.json) | -| `ecg.sloperange.synthetic_boundary_nan` | [conformance/ecg/sloperange/synthetic_boundary_nan.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/sloperange/synthetic_boundary_nan.json) | -| `ecg.sloperange.synthetic_positive` | [conformance/ecg/sloperange/synthetic_positive.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/ecg/sloperange/synthetic_positive.json) | diff --git a/docs/generated/specifications/hrv.fdmetrics.md b/docs/generated/specifications/hrv.fdmetrics.md deleted file mode 100644 index bbd979d..0000000 --- a/docs/generated/specifications/hrv.fdmetrics.md +++ /dev/null @@ -1,130 +0,0 @@ -# Frequency-domain HRV metrics - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `hrv.fdmetrics` | -| Module | `hrv` | -| Source JSON | [specs/hrv/fdmetrics/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/hrv/fdmetrics/spec.json) | - -## Summary - -Integrates conventional LF and HF powers or respiration-separated OSP powers on an authoritative frequency grid. - -The shared contract preserves the two mature Biosigmat fdmetrics call forms while making band selection, warning aggregation, missing-data behavior, and the robust respiration-separated ratio reproducible across languages. - -## Keywords - -`heart rate variability`, `frequency domain`, `low frequency`, `high frequency`, `orthogonal subspace projection`, `cardiorespiratory interaction` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `task_force_hrv_1996` | metric_definition | Provides the conventional LF and HF frequency bands and normalized frequency-domain HRV measures. | -| `varon_unconstrained_hrv_osp_2019` | method_extension | Supports frequency-domain analysis after separating respiration-related and unrelated HRV modulation with OSP. | -| `liu_robust_cardiorespiratory_index_2019` | metric_definition | Defines the robust normalization of respiration-unrelated LF power by total respiration-related and unrelated power. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `pxx` | real_vector | vector | caller-defined power/Hz | true | false | minimum=0, minimum_length=1 | -| `related_pxx` | real_vector | vector | 1/Hz | true | false | minimum=0, minimum_length=1 | -| `unrelated_pxx` | real_vector | vector | 1/Hz | true | false | minimum=0, minimum_length=1 | -| `f` | real_vector | vector | Hz | false | false | minimum=0, minimum_length=1 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `limit_hf` | boolean | true | | None | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `hf` | real_scalar | scalar | caller-defined power | -| `lf` | real_scalar | scalar | caller-defined power | -| `lfn` | real_scalar | scalar | 1 | -| `lfhf` | real_scalar | scalar | 1 | -| `urlf` | real_scalar | scalar | 1 | -| `re` | real_scalar | scalar | 1 | -| `r` | real_scalar | scalar | 1 | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `canonical_api` | One public operation has two mutually exclusive call forms. Single-spectrum mode takes pxx and f, with optional boolean limit_hf defaulting to true, and returns hf, lf, lfn, and lfhf. Separated OSP mode takes related_pxx, unrelated_pxx, and f and returns urlf, re, and r. The second mode has no limit_hf option. Implementations must reject missing, mixed, or ambiguous mode-specific input sets. Language bindings may preserve mature positional overloads: a third logical scalar selects the single-spectrum option, while a third numeric frequency vector selects separated mode. | | -| `frequency_grid` | f is authoritative and must be a nonempty real vector of finite, nonnegative, strictly increasing frequencies. Do not resample, interpolate, or insert samples at 0.04, 0.15, or 0.4 Hz. Every selected PSD must have exactly the same number of samples as f. | | -| `psd_validation` | Every selected PSD must be a nonempty real vector. Negative and infinite values are invalid. A PSD containing any NaN follows the mode-wide NaN return instead of raising an error. In single-spectrum mode the input unit is deliberately caller-defined, for example ms^2/Hz or 1/Hz. In separated mode related_pxx and unrelated_pxx are PSDs of the dimensionless respiration-related and respiration-unrelated modulation components and have unit 1/Hz. | | -| `trapezoidal_integration` | For inclusive zero-based indices a through b, integrate only the original selected samples as sum from i = a to b - 1 of (f[i+1] - f[i]) * (p[i] + p[i+1]) / 2. A selection containing exactly one sample therefore integrates to zero. | | -| `band_indices` | Let i_lf_start be the first index with f >= 0.04 and i_lf_end be the first index with f >= 0.15. The LF selection includes both indices. The HF selection starts at i_lf_end. When limit_hf is true and at least one sample has f >= 0.4, its end is the first such index; when no sample reaches 0.4, its end is the final index. When limit_hf is false, its end is always the final index. The boundary sample at i_lf_end belongs to both LF and HF trapezoidal selections, but no finite interval is counted twice. | | -| `single_powers` | In single-spectrum mode, lf is the trapezoidal integral of pxx from i_lf_start through i_lf_end and hf is the integral from i_lf_end through the selected HF end. The powers inherit the integrated caller unit. There is no magnitude rejection for lf or hf; in particular, powers greater than 15000 remain valid. | | -| `single_ratios` | When both required powers are strictly positive, lfn = lf / (lf + hf) and lfhf = lf / hf. Both ratios are dimensionless. | | -| `separated_powers` | In separated OSP mode, urlf is the LF-band trapezoidal integral of unrelated_pxx from i_lf_start through i_lf_end. re is the trapezoidal integral of related_pxx over the complete supplied frequency grid, independent of LF/HF coverage. Apply the retained empirical re rejection only to re: if its finite raw value is greater than 0.05, return re as NaN. Apply the retained empirical urlf rejection only to urlf: if its finite raw value is greater than 0.003, return urlf as NaN. Either rejected component makes r NaN but does not change the other component. | | -| `robust_ratio` | When the unrejected raw urlf is strictly positive and re is nonnegative, r = urlf / (re + urlf). Exact re = 0 is valid and gives r = 1. The ratio is dimensionless and bounded from 0 through 1 for valid powers. | | -| `vlf_diagnostic` | Evaluate the VLF diagnostic independently for pxx in single-spectrum mode and for related_pxx and unrelated_pxx in separated mode. If f has no sample below 0.04 Hz, emit no VLF warning. Otherwise, when a boundary index i_vlf equal to the first f >= 0.04 exists, integrate P_vlf from the first sample through i_vlf and P_rest from i_vlf through the final sample. Emit excessive_vlf_power for a spectrum when P_vlf / P_rest > 0.05. If P_rest = 0 and P_vlf > 0, the condition is true; if both are zero, it is false. If no i_vlf exists, the diagnostic is not evaluable and emits no warning. | | -| `warning_aggregation` | Emit at most one warning per warning id and call. excessive_vlf_power aggregates every offending selected PSD using affected input ids pxx, related_pxx, or unrelated_pxx. zero_required_power aggregates every exactly zero required band power using affected output ids lf and hf in single-spectrum mode or urlf in separated mode. A call may emit both warning ids because they represent distinct conditions. Warning ordering and message text are not normative; the canonical id and complete affected-id set are normative. | | -| `nan_mode_return` | If any selected PSD contains NaN, return NaN for every output of the selected mode and emit neither excessive_vlf_power nor zero_required_power. The unselected mode's outputs are not part of the call. | | -| `insufficient_band_return` | If the required LF or HF index selection does not exist, return NaN for every output of the selected mode without a zero-power warning. Partial nominal coverage is otherwise valid: the default HF selection may end below 0.4 at the last supplied sample. | | -| `zero_power_return` | After successful selection and integration, exact zero lf or hf makes all four single-spectrum outputs NaN and emits zero_required_power identifying every zero band. Exact zero urlf makes all three separated outputs NaN and emits zero_required_power identifying urlf. This includes a one-sample band whose trapezoidal integral is zero. Exact zero re is not an error and does not emit a warning when urlf is positive. The VLF diagnostic remains independent and may also warn on the same call. | | -| `comparison` | Each conformance case defines its absolute tolerance and uses zero relative tolerance; ordinary subunit powers and ratios use 1e-12, while large caller-unit powers use a scale-appropriate absolute tolerance. NaN outputs compare equal only when the conformance case enables nan_equal. Expected warnings compare as an unordered set of canonical warning ids, and each affected_ids value compares as an unordered complete set. Absence of expected_warnings means that no warning is expected. | | -| `error_categories` | Use invalid_type for non-numeric PSD or frequency inputs and for a non-boolean limit_hf; invalid_shape for non-vector PSD/f inputs or a non-scalar option; and invalid_value for empty, infinite, or negative PSD values, empty, NaN, infinite, negative, or non-increasing frequencies, unequal PSD/f lengths, or invalid combinations of mode-specific inputs. Language-specific exception classes, warning classes, and message text are not normative. | | - -## Warnings - -| id | condition | effect | aggregation | -| --- | --- | --- | --- | -| `excessive_vlf_power` | At least one selected PSD satisfies the normative VLF-to-rest power condition. | Diagnostic only; returned values and validation behavior are unchanged. | Emit once and identify the complete set of offending PSD input ids. | -| `zero_required_power` | At least one successfully selected required band power is exactly zero. | Return NaN for every output of the selected mode. | Emit once and identify the complete set of zero required power output ids. | - -## Behavior - -### Nan handling - -NaN is permitted only as a PSD missing-data marker. If any selected PSD contains NaN, all outputs of that mode are NaN and no warning is emitted. NaN in f is invalid. - -### Empty input - -Every selected PSD and f must be nonempty; empty vectors are invalid rather than missing-data returns. - -### Input orientation - -MATLAB row and column input vectors represent the same canonical sequences. Python accepts one-dimensional vectors. Every returned metric is scalar. - -### Insufficient data - -Missing required band indices produce a mode-wide NaN return. A present one-sample required band is instead an exact zero power, produces the zero_required_power warning, and also causes the mode-wide NaN return. Partial HF coverage below 0.4 Hz remains processable. - -## Informative Notes - -* Conventional LF and HF labels describe frequency bands and do not by themselves identify unique autonomic mechanisms. -* Band edges are selected from the supplied frequency samples without interpolation, so coarse or irregular grids can materially affect the result. -* The VLF diagnostic and the retained OSP rejection thresholds are empirical compatibility rules rather than universal physiological validity criteria. -* The former single-spectrum 15000 power rejection is intentionally absent because its meaning depended on caller units. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `hrv.fdmetrics.default_bands_irregular_grid` | [conformance/hrv/fdmetrics/default_bands_irregular_grid.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/default_bands_irregular_grid.json) | -| `hrv.fdmetrics.insufficient_band_all_nan` | [conformance/hrv/fdmetrics/insufficient_band_all_nan.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/insufficient_band_all_nan.json) | -| `hrv.fdmetrics.large_single_spectrum_retained` | [conformance/hrv/fdmetrics/large_single_spectrum_retained.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/large_single_spectrum_retained.json) | -| `hrv.fdmetrics.nan_separated_all_nan_no_warning` | [conformance/hrv/fdmetrics/nan_separated_all_nan_no_warning.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/nan_separated_all_nan_no_warning.json) | -| `hrv.fdmetrics.one_sample_hf_zero` | [conformance/hrv/fdmetrics/one_sample_hf_zero.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/one_sample_hf_zero.json) | -| `hrv.fdmetrics.partial_default_hf_coverage` | [conformance/hrv/fdmetrics/partial_default_hf_coverage.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/partial_default_hf_coverage.json) | -| `hrv.fdmetrics.related_power_threshold_rejection` | [conformance/hrv/fdmetrics/related_power_threshold_rejection.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/related_power_threshold_rejection.json) | -| `hrv.fdmetrics.separated_vlf_warning` | [conformance/hrv/fdmetrics/separated_vlf_warning.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/separated_vlf_warning.json) | -| `hrv.fdmetrics.single_vlf_warning_preserves_metrics` | [conformance/hrv/fdmetrics/single_vlf_warning_preserves_metrics.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/single_vlf_warning_preserves_metrics.json) | -| `hrv.fdmetrics.unlimited_hf_irregular_grid` | [conformance/hrv/fdmetrics/unlimited_hf_irregular_grid.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/unlimited_hf_irregular_grid.json) | -| `hrv.fdmetrics.unrelated_power_threshold_rejection` | [conformance/hrv/fdmetrics/unrelated_power_threshold_rejection.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/unrelated_power_threshold_rejection.json) | -| `hrv.fdmetrics.vlf_and_zero_warnings` | [conformance/hrv/fdmetrics/vlf_and_zero_warnings.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/vlf_and_zero_warnings.json) | -| `hrv.fdmetrics.zero_related_power_robust_ratio` | [conformance/hrv/fdmetrics/zero_related_power_robust_ratio.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/zero_related_power_robust_ratio.json) | -| `hrv.fdmetrics.zero_single_powers_aggregated` | [conformance/hrv/fdmetrics/zero_single_powers_aggregated.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/zero_single_powers_aggregated.json) | -| `hrv.fdmetrics.zero_urlf_atomic` | [conformance/hrv/fdmetrics/zero_urlf_atomic.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fdmetrics/zero_urlf_atomic.json) | diff --git a/docs/generated/specifications/hrv.fillgaps.md b/docs/generated/specifications/hrv.fillgaps.md deleted file mode 100644 index 9477d2d..0000000 --- a/docs/generated/specifications/hrv.fillgaps.md +++ /dev/null @@ -1,106 +0,0 @@ -# Missing-event gap filling - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `hrv.fillgaps` | -| Module | `hrv` | -| Source JSON | [specs/hrv/fillgaps/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/hrv/fillgaps/spec.json) | - -## Summary - -Reconstructs missing event timestamps by iteratively interpolating intervals inside locally detected gaps. - -The method preserves every original event timestamp and attempts progressively larger insertion counts in unresolved long intervals. Each reconstruction uses PCHIP interpolation from nearby valid intervals, is rescaled to the exact gap duration, and is accepted or rolled back according to local interval bounds. - -## Keywords - -`event times`, `missing events`, `HRV preprocessing`, `PCHIP interpolation` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `cajal_missing_data_hrv_2022` | preprocessing_guidance | Documents the missing-data problem in HRV analysis and the original empirical gap-detection and correction factors; the canonical defaults include later Biosigmat refinements recorded by this contract. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `tk` | real_vector | vector | s | false | false | minimum_length=1 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `gap_detection_factor` | real_scalar | 1.5 | 1 | exclusive_minimum=0 | -| `correction_upper_factor` | real_scalar | 1.15 | 1 | exclusive_minimum=0 | -| `correction_lower_factor` | real_scalar | 0.75 | 1 | exclusive_minimum=0 | -| `minimum_interval` | real_scalar | 0.5 | s | minimum=0 | -| `max_gap_duration` | real_scalar | 10 | s | exclusive_minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `tn` | real_vector | vector | s | -| `dtn` | real_vector | vector | s | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `tk` | tk is a non-empty ordered vector of finite event timestamps expressed in seconds. The time origin is unrestricted, so negative timestamps are valid. tk is assumed to have already undergone any desired false-positive removal; fillgaps must not invoke hrv.removefp internally. | | -| `event_order` | Event timestamps must be strictly increasing: every timestamp must be greater than the preceding timestamp. Unsorted timestamps and duplicate timestamps are invalid, and implementations must not sort tk implicitly. | | -| `parameter_relationships` | All five parameters must be finite. The three factors must satisfy 0 < correction_lower_factor < correction_upper_factor <= gap_detection_factor. minimum_interval must be greater than or equal to 0 s, and max_gap_duration must be strictly positive. | | -| `adaptive_baseline` | For a series with at least three events, compute its successive intervals and apply tools.medfilt_threshold with window = 30 samples, factor = 1, and max_threshold = 1.5 s. Recompute this aligned baseline after every complete insertion-count pass. | | -| `gap_detection` | An interval is a gap only when it is strictly greater than both gap_detection_factor times its aligned adaptive baseline and minimum_interval. Equality to either boundary is not a gap. A detected gap whose duration exceeds max_gap_duration is uncorrectable. | | -| `segment_wide_iteration` | Start with one inserted event for every unresolved correctable gap in the complete series. Finish that pass for every such gap before attempting two insertions in any still-unresolved gap, then continue with three and higher insertion counts without an arbitrary maximum. Attempts within a pass use the interval series, baseline, and unresolved-gap set at the start of that pass; accepted results are applied together after the pass, followed by interval, baseline, and gap recomputation. | | -| `interpolation_support` | For a gap attempt with N inserted events, select the two nearest valid intervals before the gap and the two nearest valid intervals after it. Intervals belonging to every other unresolved gap are excluded while searching outward. The search distance is unbounded, but no extrapolation or lower-order interpolation is allowed; a gap without two valid intervals on each side is uncorrectable. | | -| `pchip_reconstruction` | Place the four support-interval values at compressed coordinates [-1, 0, N + 2, N + 3] in chronological order. Evaluate shape-preserving piecewise cubic Hermite interpolation at integer coordinates 1 through N + 1. Rescale all N + 1 reconstructed intervals by one common factor so their sum equals the original gap duration exactly, then insert N events at their cumulative offsets from the original event before the gap. | | -| `sufficient_reconstruction` | After first applying the over-insertion rule, a reconstruction is sufficient only when every one of its N + 1 intervals is strictly less than correction_upper_factor times the baseline aligned with the original gap at the start of the current pass. If it is sufficient and is not over-inserted, accept it and finalize that gap. | | -| `over_insertion` | A reconstruction is over-inserted only when every one of its N + 1 intervals is strictly less than max(correction_lower_factor times the baseline aligned with the original gap at the start of the current pass, minimum_interval). Evaluate over-insertion before sufficiency; because the lower boundary is below the upper boundary, an over-inserted attempt also satisfies the upper test, but the fallback rule takes precedence. If an attempt is over-inserted, finalize the gap using the preceding insertion-count reconstruction, even when that preceding reconstruction did not satisfy the upper bound. Because iteration starts at N = 1, an over-inserted N = 1 attempt leaves the gap unresolved with no inserted events. | | -| `original_events` | tn contains every original timestamp from tk unchanged and in its original order. Reconstruction can only add timestamps strictly inside a gap; it must never remove or displace original events. | | -| `unresolved_gap_output` | If a gap is uncorrectable or remains unresolved, preserve both original timestamps spanning it and insert no event in that span. Compute dtn as diff(tn), then replace the single dtn element spanning every unresolved gap with NaN. This rule also applies when no gap can be attempted and the algorithm returns early. | | -| `dtn` | dtn is the successive-difference vector of tn, with length max(length(tn) - 1, 0), except that intervals spanning unresolved gaps are represented by NaN as defined above. | | - -## Behavior - -### Nan handling - -NaN, Inf, and -Inf timestamps are invalid. NaN does not represent a missing event in tk; a missed event is represented by the resulting abnormally long finite interval. NaN is used only in dtn to mark an unresolved gap span. - -### Empty input - -Empty tk input is invalid. - -### Input orientation - -Row and column vectors represent the same canonical event-time sequence. Output orientation is implementation-specific and is not part of the language-independent contract. - -### Insufficient data - -A single event is valid and returns tn equal to tk with empty dtn. A two-event series is valid; without enough baseline and two-sided interpolation support, its timestamps are preserved and any detected unresolved gap would be represented by NaN in dtn. Small valid inputs must not fail merely because reconstruction support is unavailable. - -## Informative Notes - -* Input event times must already be strictly increasing; implementations must not sort them implicitly. -* fillgaps receives an already cleaned event series and must not call hrv.removefp internally. The recommended preprocessing order is hrv.removefp followed by hrv.fillgaps. -* The default factors are empirical algorithm settings and are not clinically validated thresholds. -* Original event timestamps are never displaced or removed, including when a gap cannot be reconstructed. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `hrv.fillgaps.insufficient_support_unresolved` | [conformance/hrv/fillgaps/insufficient_support_unresolved.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fillgaps/insufficient_support_unresolved.json) | -| `hrv.fillgaps.over_insertion_fallback` | [conformance/hrv/fillgaps/over_insertion_fallback.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fillgaps/over_insertion_fallback.json) | -| `hrv.fillgaps.over_maximum_duration_unresolved` | [conformance/hrv/fillgaps/over_maximum_duration_unresolved.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fillgaps/over_maximum_duration_unresolved.json) | -| `hrv.fillgaps.pchip_single_insertion` | [conformance/hrv/fillgaps/pchip_single_insertion.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fillgaps/pchip_single_insertion.json) | -| `hrv.fillgaps.regular_series_unchanged` | [conformance/hrv/fillgaps/regular_series_unchanged.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fillgaps/regular_series_unchanged.json) | -| `hrv.fillgaps.segment_wide_iteration` | [conformance/hrv/fillgaps/segment_wide_iteration.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fillgaps/segment_wide_iteration.json) | -| `hrv.fillgaps.single_event` | [conformance/hrv/fillgaps/single_event.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/fillgaps/single_event.json) | diff --git a/docs/generated/specifications/hrv.ipfm.md b/docs/generated/specifications/hrv.ipfm.md deleted file mode 100644 index 2943a9e..0000000 --- a/docs/generated/specifications/hrv.ipfm.md +++ /dev/null @@ -1,106 +0,0 @@ -# Integral pulse frequency modulation heart-timing reconstruction - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `hrv.ipfm` | -| Module | `hrv` | -| Source JSON | [specs/hrv/ipfm/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/hrv/ipfm/spec.json) | - -## Summary - -Estimates uniformly sampled instantaneous heart rate and an optional TVIPFM autonomic modulating signal from event times. - -The canonical contract reconstructs cumulative beat count with an edge-stabilized high-order B-spline, differentiates it to obtain instantaneous heart rate, and optionally applies the time-varying-threshold IPFM correction. It covers sampled numerical outputs only; language-specific unevaluated spline objects are outside shared conformance. - -## Keywords - -`heart timing signal`, `instantaneous heart rate`, `IPFM`, `TVIPFM`, `B-spline interpolation` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `mateo_laguna_ipfm_2000` | original_method | Original heart-timing/IPFM formulation and explicit analysis of fourteenth-order spline interpolation. The paper does not prescribe the canonical 10/8 edge constants. | -| `mateo_laguna_ectopic_ht_2003` | validation | Validates heart-timing analysis in the presence of ectopic beats and uses fourteenth-order spline interpolation after incorrect values are removed. | -| `bailon_tvipfm_2011` | original_method | Defines TVIPFM Approach A, including the time-varying mean-rate correction and the 0.03 Hz separation used here. | -| `bailon_tvipfm_2011` | validation | Validates the TVIPFM correction during exercise stress testing; it does not prescribe Biosigmat's exact fourth-order Butterworth and forward-backward realization. | -| `sornmo_bailon_laguna_hrv_review_2024` | scientific_context | Provides the later derivation and review context for the TVIPFM correction under a time-varying mean heart rate. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `tn` | real_vector | vector | s | false | false | minimum_length=2 | -| `fs` | real_scalar | scalar | Hz | false | false | exclusive_minimum=0 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `spline_order` | integer_scalar | 14 | 1 | minimum=2 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `ihr` | real_vector | vector | Hz | -| `m` | real_vector | vector | 1 | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `canonical_api` | The language-independent API requires tn and fs and returns numerical values evaluated on the canonical uniform grid. ihr is always available. m is an optional output computed only when requested. Returning an unevaluated spline representation, including the MATLAB ipfm(tn) convenience mode, is outside this contract. | | -| `tn` | tn is a finite real event-time vector containing at least two timestamps in seconds. It must be strictly increasing, and implementations must not sort or deduplicate it implicitly. | | -| `boundary_extension` | Let d = diff(tn) and q = min(8, length(d)). Prepend 10 virtual events separated by median(d[0:q]) and append 10 virtual events separated by median(d[length(d)-q:length(d)]). The resulting extended event sequence tau remains strictly increasing and contains N = length(tn) + 20 sites. The virtual events stabilize interpolation but do not enlarge the valid output domain. | | -| `spline_order` | spline_order is an integer satisfying 2 <= spline_order <= N, where N is the number of extended event sites. The default is 14. Order k means polynomial degree k - 1; an implementation must reject an order above N instead of silently reducing it. | | -| `aptknt_knot_sequence` | For spline order k and strictly increasing extended sites tau_1 through tau_N, form interior knots xi_i = mean(tau_{i+1}, ..., tau_{i+k-1}) for i = 1, ..., N - k. The complete knot vector is tau_1 repeated k times, followed by xi_1 through xi_{N-k}, followed by tau_N repeated k times. This is the MATLAB aptknt construction for these sites and must be used instead of a language-specific default knot placement. | | -| `heart_timing_spline` | Construct the unique order-k B-spline on the canonical knot vector that interpolates cumulative beat indices 1 through N at tau_1 through tau_N. Differentiate this spline once with respect to time. The derivative is instantaneous rate because the interpolated dependent variable is cumulative beat count and event time is measured in seconds. | | -| `sampling_grid` | The output grid contains t_j = tn[0] + j / fs for every non-negative integer j satisfying t_j <= tn[-1]. Construct it from integer indices and remove any floating-point overshoot beyond tn[-1]. Do not append an irregular endpoint or alter fs to force tn[-1] onto the grid. Never evaluate outside the original interval [tn[0], tn[-1]], and disable spline extrapolation where the implementation API permits it. | | -| `ihr` | Evaluate the differentiated heart-timing spline at every canonical grid point. ihr is the resulting unfiltered instantaneous heart-rate vector in hertz and has the same length as the grid. Every value must be finite and strictly positive. | | -| `tvipfm_filter` | When m is requested, design a fourth-order digital Butterworth low-pass filter with cutoff 0.03 Hz, equivalently normalized cutoff 0.06 / fs relative to Nyquist. Apply it forward and backward to ihr for zero phase. Filter family, order, cutoff, and zero-phase application are fixed and are not public parameters. | | -| `forward_backward_filter_convention` | Reproduce the MATLAB filtfilt padding convention for the five-coefficient Butterworth numerator and denominator: extend each end by exactly 12 samples, use odd-symmetry linear reflection about each endpoint, and use steady-state initial conditions scaled by the endpoint of the extended signal on each pass. SciPy implementations must use method = pad, padtype = odd, and padlen = 12 explicitly. | | -| `m` | Let mean_ihr be the forward-backward low-pass result and hrv = ihr - mean_ihr. The TVIPFM Approach A modulating signal is m = hrv / mean_ihr. m is dimensionless and has the same length as ihr. The division corrects the scaling caused by a time-varying threshold or mean heart rate; it must not be described as generic detrending alone. | m(t) = \frac{ihr(t) - mean\_ihr(t)}{mean\_ihr(t)} | -| `numerical_validity` | Do not clamp ihr or mean_ihr, take absolute values, or replace a non-positive denominator with an epsilon. If spline evaluation produces any non-finite or non-positive ihr, or filtering produces any non-finite or non-positive mean_ihr, raise invalid_numerical_result. | | -| `error_categories` | Use invalid_type for non-numeric inputs, invalid_shape for non-vector tn or non-scalar fs/spline_order, invalid_value for non-finite values, non-increasing tn, fs outside the requested-output domain, or spline_order outside its valid range, insufficient_data when tn has fewer than two events or requested m has fewer than 13 grid samples, and invalid_numerical_result for the defensive numerical failures defined above. Language-specific exception classes and message text are not normative. | | - -## Behavior - -### Nan handling - -NaN, Inf, and -Inf are invalid in tn and fs. NaN and infinite output values are never conformant numerical results and raise invalid_numerical_result. - -### Empty input - -Empty tn is insufficient_data. An omitted fs is outside the canonical sampled API. - -### Input orientation - -MATLAB row and column tn inputs represent the same sequence, and canonical MATLAB numerical outputs are column vectors. Python accepts and returns one-dimensional arrays. - -### Insufficient data - -At least two event times are required. Computing only ihr requires fs > 0 and does not invoke the TVIPFM filter, so 12 or fewer grid samples remain valid. Requesting m additionally requires fs > 0.06 Hz and at least 13 canonical grid samples; 12 or fewer grid samples raise insufficient_data. - -## Informative Notes - -* The default order-14 spline is supported by the Mateo-Laguna heart-timing literature. -* The 10 virtual events per side and the use of up to 8 boundary intervals are empirical stabilization constants inherited from Biosigmat, not physiological or clinically validated parameters. -* The 0.03 Hz TVIPFM separation is literature-backed, while the exact fourth-order Butterworth realization and forward-backward edge convention are fixed numerical compatibility choices inherited from Biosigmat. -* Implementations must not expose the virtual-event count, boundary-median width, trend cutoff, filter order, filter family, or forward-backward padding length as canonical public parameters. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `hrv.ipfm.constant_rate` | [conformance/hrv/ipfm/constant_rate.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/ipfm/constant_rate.json) | -| `hrv.ipfm.insufficient_modulating_signal_samples` | [conformance/hrv/ipfm/insufficient_modulating_signal_samples.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/ipfm/insufficient_modulating_signal_samples.json) | -| `hrv.ipfm.invalid_numerical_rate` | [conformance/hrv/ipfm/invalid_numerical_rate.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/ipfm/invalid_numerical_rate.json) | -| `hrv.ipfm.medicom_mtd_tvipfm` | [conformance/hrv/ipfm/medicom_mtd_tvipfm.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/ipfm/medicom_mtd_tvipfm.json) | -| `hrv.ipfm.non_aligned_sampling_grid` | [conformance/hrv/ipfm/non_aligned_sampling_grid.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/ipfm/non_aligned_sampling_grid.json) | -| `hrv.ipfm.spline_order_exceeds_sites` | [conformance/hrv/ipfm/spline_order_exceeds_sites.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/ipfm/spline_order_exceeds_sites.json) | diff --git a/docs/generated/specifications/hrv.osp.md b/docs/generated/specifications/hrv.osp.md deleted file mode 100644 index 784557c..0000000 --- a/docs/generated/specifications/hrv.osp.md +++ /dev/null @@ -1,116 +0,0 @@ -# Respiration-related HRV decomposition by orthogonal subspace projection - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `hrv.osp` | -| Module | `hrv` | -| Source JSON | [specs/hrv/osp/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/hrv/osp/spec.json) | - -## Summary - -Separates a uniformly sampled HRV modulating signal into a component linearly related to respiration and an orthogonal residual. - -The canonical contract preserves the mature Biosigmat OSP workflow: it estimates a dominant respiratory frequency from a supplied spectrum, uses approximately two respiratory cycles to set an adaptive delayed-respiration model order, and projects the aligned HRV modulation onto that subspace. - -## Keywords - -`heart rate variability`, `respiration`, `orthogonal subspace projection`, `cardiorespiratory interaction`, `linear decomposition` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `varon_respiratory_hrv_osp_2017` | method_extension | Applies OSP to separate respiration-related and residual HRV dynamics and compares delayed-respiration and wavelet respiratory subspaces. | -| `varon_unconstrained_hrv_osp_2019` | original_method | Defines and validates the HRV analysis approach based on removing linear respiratory influences with OSP. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `m` | real_vector | vector | 1 | true | false | None | -| `resp` | real_vector | vector | a.u. | true | false | None | -| `resp_pxx` | real_vector | vector | a.u.^2/Hz | false | false | minimum_length=2 | -| `f` | real_vector | vector | Hz | false | false | minimum_length=2 | -| `fs` | real_scalar | scalar | Hz | false | false | exclusive_minimum=0 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `min_resp_frequency` | real_scalar | 0.1 | Hz | exclusive_minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `m_resp` | real_vector | vector | 1 | -| `m_unrelated` | real_vector | vector | 1 | -| `delay` | integer_scalar | scalar | sample | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `canonical_api` | The language-independent API requires aligned HRV modulation m, respiration resp, respiratory PSD resp_pxx, its frequency vector f, and sampling frequency fs. It returns the respiration-related component m_resp, residual m_unrelated, and adaptive model order delay. Spectrum estimation itself is outside this contract. | | -| `aligned_signals` | m and resp are real sample sequences on the same uniform fs grid and with the same time origin. After the approved empty and NaN early returns, they must have equal lengths. Implementations must not resample, shift, detrend, normalize, or otherwise preprocess either sequence implicitly. | | -| `respiratory_spectrum` | resp_pxx and f must have the same length L >= 2. Every resp_pxx value must be nonnegative. f must be strictly increasing, but the contract does not require a zero first frequency, uniform spacing, or a particular relation between its last value and fs. | | -| `occupied_power_integration` | Reproduce the current 90% occupied-power calculation in double precision. Let delta_bar = (f[L-1] - f[0]) / (L - 1). If f[0] = 0, use rectangle widths w[i] = f[i+1] - f[i] for i = 0, ..., L-2 and w[L-1] = delta_bar. Otherwise use w[0] = delta_bar and w[i] = f[i] - f[i-1] for i = 1, ..., L-1. Let power[i] = resp_pxx[i] * w[i], cumulative powers c[0] = 0 and c[i+1] = c[i] + power[i], and cumulative-frequency locations b[0] = f[0], b[i] = (f[i-1] + f[i]) / 2 for i = 1, ..., L-1, and b[L] = f[L-1]. For each threshold T equal to 5% or 95% of c[L], select the first cumulative index j for which T <= c[j], replacing j = 0 by j = 1, and linearly interpolate frequency between (c[j-1], b[j-1]) and (c[j], b[j]). If total power is zero, this interpolation divides zero by zero and both limits are NaN. | | -| `occupied_band_samples` | Select every spectral sample whose frequency is greater than or equal to the interpolated lower occupied limit and less than or equal to the upper limit. Both boundaries are inclusive. If no spectral sample is selected, including when zero total power produced NaN limits, fall back to the complete resp_pxx and f vectors. | | -| `candidate_peaks` | If the selected spectrum has fewer than three samples, skip peak detection and choose its first maximum. Otherwise detect local maxima in frequency order. The first and last selected samples are not peaks. A flat peak contributes only its lowest-index sample on the rising edge. If no peak is found, choose the first maximum of the selected spectrum. With one to three peaks, choose the peak with greatest power; equal-power ties select the first, lowest-frequency peak. With more than three peaks, choose the lowest-frequency peak regardless of power. | | -| `dominant_frequency` | Let the frequency selected by the peak-dependent rule be f_selected. Set dominant_frequency = max(f_selected, min_resp_frequency). The entire selection rule and the default 0.1 Hz floor are empirical Biosigmat heuristics and must not be replaced by a simpler global maximum or attributed to the cited OSP literature. | | -| `delay` | Compute delay = max(round_half_away_from_zero(2 * fs / dominant_frequency), 1). All operands are positive, so an exact fractional tie ending in .5 rounds upward. delay is both the number of delayed-respiration regressors and the first one-based sample index represented by the returned components. | q = \max\left(\operatorname{round}_{\mathrm{half\ away}}\left(\frac{2 f_s}{f_{resp}}\right), 1\right) | -| `alignment` | For N >= delay, discard the first delay - 1 samples. Both returned components correspond to m[delay-1:N] with zero-based indexing, equivalently m(delay:end) in MATLAB, and have length N - delay + 1. | | -| `respiratory_subspace` | For q = delay and N >= q, construct V with N - q + 1 rows and q columns using V[r,c] = resp[r+c] for zero-based r = 0, ..., N-q and c = 0, ..., q-1. Thus each row is one q-sample sliding respiration window and the first row spans resp[0:q]. | | -| `gram_pseudoinverse` | Form G = transpose(V) * V and compute its singular-value decomposition G = U * diag(s) * transpose(W), with singular values in descending order. Let sigma_max = s[0] and tol = max(rows(G), columns(G)) * eps(sigma_max), where eps(x) is the distance from finite IEEE 754 binary64 x to the next larger representable value. Define s_plus[i] = 1 / s[i] only when s[i] is strictly greater than tol and zero otherwise, then G_plus = W * diag(s_plus) * transpose(U). Apply this threshold to G, not to V, and do not use a language-specific default pseudoinverse threshold. Rank-deficient finite subspaces are valid. | | -| `decomposition` | Form P = V * G_plus * transpose(V), delayed_m = m[delay-1:N], m_resp = P * delayed_m, and m_unrelated = delayed_m - m_resp. This separates the part represented by the delayed-respiration subspace from the remaining dynamics. | m_{resp} = V(V^\mathsf{T}V)^+V^\mathsf{T}m_{delayed}, \qquad m_{unrelated} = m_{delayed} - m_{resp} | -| `reconstruction` | For every finite processed case, m_resp + m_unrelated reconstructs delayed_m. Shared numerical comparisons of either component and of reconstruction use absolute tolerance 1e-10 and zero relative tolerance. delay is compared exactly. | | -| `residual_orthogonality` | On the analytical orthogonality case, verify norm(transpose(V) * m_unrelated) / max(norm(transpose(V) * delayed_m), eps) < 1e-8 using the Euclidean norm and eps = 2.220446049250313e-16. This external check is deliberately distinct from the internal Gram pseudoinverse tolerance because forming transpose(V) * V squares the subspace condition number. | | -| `early_return_order` | After validating the common argument types, shapes, spectrum, fs, and parameter, return all three outputs empty if either m or resp is empty. Next, return all three outputs empty if either signal contains any NaN. These two returns occur before the m/resp length-equality check. After them, reject any infinite signal value explicitly and require equal signal lengths. | | -| `error_categories` | Use invalid_type for non-numeric inputs, invalid_shape for non-vector signals or spectra and non-scalar fs/min_resp_frequency, and invalid_value for infinite m/resp values, negative or non-finite resp_pxx, non-finite or non-increasing f, unequal spectrum lengths, non-positive fs/min_resp_frequency, or unequal non-empty finite signal lengths. Language-specific exception classes and message text are not normative. | | - -## Behavior - -### Nan handling - -NaN is permitted only in m and resp as a compatibility marker: if either signal contains NaN, all three outputs are empty. NaN in resp_pxx, f, fs, or min_resp_frequency is invalid. Inf and -Inf are invalid in every input and parameter; in particular, infinite m or resp values raise invalid_value instead of entering spectral or linear-algebra processing. - -### Empty input - -If either m or resp is empty, return empty m_resp, empty m_unrelated, and empty delay, even when the other signal is non-empty. resp_pxx, f, fs, and min_resp_frequency must still satisfy their common argument constraints. - -### Input orientation - -MATLAB row and column input vectors represent the same canonical sequences, and its processed vector outputs are columns. Python accepts and returns one-dimensional arrays. Empty-output orientation is implementation-specific. - -### Insufficient data - -If non-empty, NaN-free, finite, equal-length signals have N < delay, return scalar NaN for m_resp and m_unrelated while preserving the computed integer delay. Do not replace these scalar NaNs with empty vectors. N >= delay, including equality, is processable. - -## Informative Notes - -* Orthogonal subspace projection supports separating linear respiratory influences from the remaining HRV dynamics; nonlinear respiratory influences may remain in the residual. -* The peak-count-dependent dominant-frequency rule, the 90% occupied-power band, and the default 0.1 Hz floor are empirical heuristics inherited from Biosigmat. The cited OSP publications do not establish them as optimal or generally required. -* The public delay output is retained for compatibility, although it is primarily the adaptive number of respiratory regressors and also fixes output alignment. -* The explicit Gram-matrix pseudoinverse threshold is a cross-language numerical compatibility rule, not a physiological tolerance. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `hrv.osp.analytical_decomposition_orthogonality` | [conformance/hrv/osp/analytical_decomposition_orthogonality.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/analytical_decomposition_orthogonality.json) | -| `hrv.osp.empty_signal_early_return` | [conformance/hrv/osp/empty_signal_early_return.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/empty_signal_early_return.json) | -| `hrv.osp.greatest_peak_tie` | [conformance/hrv/osp/greatest_peak_tie.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/greatest_peak_tie.json) | -| `hrv.osp.halfway_rounding_alignment` | [conformance/hrv/osp/halfway_rounding_alignment.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/halfway_rounding_alignment.json) | -| `hrv.osp.infinite_signal_error` | [conformance/hrv/osp/infinite_signal_error.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/infinite_signal_error.json) | -| `hrv.osp.minimum_frequency_override` | [conformance/hrv/osp/minimum_frequency_override.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/minimum_frequency_override.json) | -| `hrv.osp.more_than_three_peaks_lowest_frequency` | [conformance/hrv/osp/more_than_three_peaks_lowest_frequency.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/more_than_three_peaks_lowest_frequency.json) | -| `hrv.osp.nan_signal_early_return` | [conformance/hrv/osp/nan_signal_early_return.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/nan_signal_early_return.json) | -| `hrv.osp.near_rank_pseudoinverse_threshold` | [conformance/hrv/osp/near_rank_pseudoinverse_threshold.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/near_rank_pseudoinverse_threshold.json) | -| `hrv.osp.occupied_band_boundary_inclusive` | [conformance/hrv/osp/occupied_band_boundary_inclusive.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/occupied_band_boundary_inclusive.json) | -| `hrv.osp.short_signal_scalar_nan` | [conformance/hrv/osp/short_signal_scalar_nan.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/short_signal_scalar_nan.json) | -| `hrv.osp.zero_spectrum_no_peak_fallback` | [conformance/hrv/osp/zero_spectrum_no_peak_fallback.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/osp/zero_spectrum_no_peak_fallback.json) | diff --git a/docs/generated/specifications/hrv.removefp.md b/docs/generated/specifications/hrv.removefp.md deleted file mode 100644 index eae1697..0000000 --- a/docs/generated/specifications/hrv.removefp.md +++ /dev/null @@ -1,90 +0,0 @@ -# False-positive event removal - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `hrv.removefp` | -| Module | `hrv` | -| Source JSON | [specs/hrv/removefp/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/hrv/removefp/spec.json) | - -## Summary - -Removes detections that follow abnormally short event-to-event intervals using a fixed adaptive-baseline rule. - -The method is a deterministic preprocessing operation for event-time series. It identifies intervals that are short relative to a local median-filtered baseline and removes the second event of every flagged pair in one simultaneous pass. - -## Keywords - -`event times`, `false positives`, `HRV preprocessing`, `adaptive baseline` - -## Scientific References - -No scientific references are listed in this specification. - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `tk` | real_vector | vector | s | false | false | minimum_length=1 | - -## Parameters - -No parameters. - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `tn` | real_vector | vector | s | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `tk` | tk is a non-empty ordered vector of finite event timestamps expressed in seconds. The time origin is unrestricted, so negative timestamps are valid. | | -| `event_order` | Event timestamps must be strictly increasing: every timestamp must be greater than the preceding timestamp. Unsorted timestamps and duplicate timestamps are invalid, and implementations must not sort tk implicitly. | | -| `original_intervals` | For an input containing at least three events, compute dtk as every successive difference of the original tk sequence before any removal. | | -| `adaptive_baseline` | Compute the adaptive baseline by applying tools.medfilt_threshold to the original dtk sequence with window = 30 samples, factor = 1, and max_threshold = 1.5 s. | | -| `false_positive_interval` | Flag an original interval only when dtk is strictly less than 0.7 times its aligned adaptive baseline. An interval equal to 0.7 times the baseline is retained. | | -| `tn` | Retain the first input event. For every flagged original interval, remove the second event of that interval; retain every other input event without modifying its timestamp. | | -| `single_pass_removal` | Compute the baseline and all interval flags on the complete original event series, then apply all removals simultaneously in one pass. Do not recompute the baseline or iterate after removal. | | -| `adjacent_false_positive_intervals` | When flagged intervals are adjacent, remove the second event of every flagged interval. This can remove multiple consecutive events while always retaining the first event of the first flagged pair. | | -| `empirical_algorithm_constants` | The baseline window of 30 samples, baseline factor of 1, 1.5 s baseline cap, and 0.7 false-positive multiplier are fixed empirical algorithm constants. They are not population-independent or clinically validated thresholds. | | - -## Behavior - -### Nan handling - -NaN, Inf, and -Inf timestamps are invalid. NaN does not represent a missing event in tk; a missed event is represented by the resulting abnormally long finite interval. - -### Empty input - -Empty tk input is invalid. - -### Input orientation - -Row and column vectors represent the same canonical event-time sequence. Output orientation is implementation-specific and is not part of the language-independent contract. - -### Insufficient data - -One- and two-event inputs are valid and are returned unchanged because there is insufficient interval context for false-positive detection. - -## Informative Notes - -* Input event times must already be strictly increasing; implementations must not sort them implicitly. -* The recommended HRV preprocessing sequence applies false-positive removal before missing-event gap filling. -* The median-filter settings and the 0.7 multiplier are empirical algorithm constants inherited from Biosigmat, not clinically validated thresholds. -* Timestamp-only processing cannot always distinguish a false detection from a nearby true event; the contract preserves the deterministic historical selection rule. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `hrv.removefp.consecutive_flagged_intervals` | [conformance/hrv/removefp/consecutive_flagged_intervals.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/removefp/consecutive_flagged_intervals.json) | -| `hrv.removefp.inserted_close_detection` | [conformance/hrv/removefp/inserted_close_detection.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/removefp/inserted_close_detection.json) | -| `hrv.removefp.regular_series_unchanged` | [conformance/hrv/removefp/regular_series_unchanged.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/removefp/regular_series_unchanged.json) | -| `hrv.removefp.strict_equality_retained` | [conformance/hrv/removefp/strict_equality_retained.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/removefp/strict_equality_retained.json) | diff --git a/docs/generated/specifications/hrv.tdmetrics.md b/docs/generated/specifications/hrv.tdmetrics.md deleted file mode 100644 index f0fa8a1..0000000 --- a/docs/generated/specifications/hrv.tdmetrics.md +++ /dev/null @@ -1,104 +0,0 @@ -# Time-domain beat or pulse variability metrics - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `hrv.tdmetrics` | -| Module | `hrv` | -| Source JSON | [specs/hrv/tdmetrics/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/hrv/tdmetrics/spec.json) | - -## Summary - -Computes standard time-domain HRV metrics from cleaned beat-to-beat or pulse-to-pulse intervals. - -The input dtk is the interval series, in seconds, after beat or pulse detection, interval construction, and preprocessing for artifacts, missed beats, false detections, ectopic beats, outliers, and missing data. Invalid intervals may be removed before calling this algorithm, or retained as NaN markers that are omitted from metric calculations. - -## Keywords - -`dtk`, `HRV`, `time-domain`, `missing data`, `artifact handling`, `wearable`, `omitnan` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `task_force_hrv_1996` | metric_definition | Supports the conventional definitions and units of time-domain variability metrics while allowing interval sequences derived from ECG, PPG, or other fiducial timing sources. | -| `cajal_missing_data_hrv_2022` | preprocessing_guidance | Supports robust handling of missing or invalid intervals when computing HRV metrics from wearable or artifact-affected interval series. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `dtk` | real_vector | vector | s | true | false | exclusive_minimum=0 | - -## Parameters - -No parameters. - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `mhr` | real_scalar | scalar | beats/min | -| `sdnn` | real_scalar | scalar | ms | -| `sdsd` | real_scalar | scalar | ms | -| `rmssd` | real_scalar | scalar | ms | -| `pnn50` | real_scalar | scalar | % | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `dtk` | dtk is the ordered vector of beat-to-beat or pulse-to-pulse intervals, expressed in seconds, after preprocessing for artifacts, missed beats, false detections, ectopic beats, outliers, and missing data. | | -| `valid_interval` | A valid interval is a finite, strictly positive, non-infinite, non-NaN element of dtk expressed in seconds. | | -| `missing_interval_marker` | NaN is an allowed missing or invalid interval marker in dtk and is omitted from all metric calculations. | | -| `mhr` | Mean heart or pulse rate is 60 / mean(valid dtk), where valid dtk is the sequence remaining after omitting NaN markers. | \mathrm{MHR} = \frac{60}{\operatorname{mean}(dTK)} | -| `sdnn` | SDNN is the sample standard deviation of valid dtk intervals after omitting NaN markers, using denominator N - 1, converted from seconds to milliseconds. | | -| `successive_interval_differences` | Successive interval differences are calculated between adjacent entries of the cleaned valid interval sequence after omitting NaN markers from dtk. NaN is not treated as zero and is not interpolated inside tdmetrics. | | -| `rmssd` | RMSSD is the square root of the mean squared successive interval differences from the cleaned valid interval sequence, converted from seconds to milliseconds. | | -| `sdsd` | SDSD is the sample standard deviation of successive interval differences from the cleaned valid interval sequence, using denominator N - 1, converted from seconds to milliseconds. | | -| `pnn50` | pNN50 is 100 * count(abs(successive interval differences) > 0.05 s) / number of successive interval differences in the cleaned valid interval sequence. The threshold is strictly greater than 50 ms. | | - -## Behavior - -### Nan handling - -NaN values in dtk are allowed and omitted from all metric calculations. Inf and -Inf intervals are invalid. Zero or negative finite intervals are invalid. tdmetrics must not silently interpolate, gap-fill, or treat invalid intervals as physiological variability. - -### Empty input - -Empty dtk input is invalid. - -### Input orientation - -Row and column vectors represent the same canonical dtk sequence and must produce scientifically equivalent outputs. - -### Insufficient data - -After omitting NaN markers, zero valid intervals produce NaN for mhr, sdnn, sdsd, rmssd, and pnn50. One valid interval produces finite mhr and NaN for sdnn, sdsd, rmssd, and pnn50. Two valid intervals produce finite mhr, sdnn, rmssd, and pnn50, while sdsd is NaN because its sample standard deviation has only one successive difference. Three or more valid intervals produce all metrics normally. Undefined individual metrics are represented by NaN and must not cause a global insufficient-data error. - -## Informative Notes - -* The canonical input is dtk, the cleaned beat-to-beat or pulse-to-pulse interval sequence. -* Intervals affected by artifacts, missed beats, false detections, ectopic beats, or outlier behavior should be detected and removed, corrected, or marked as NaN before calling the algorithm. -* NaN intervals are explicit missing or invalid interval markers and are ignored in all metric calculations, equivalent to omit-NaN behavior. -* The function must not silently treat invalid intervals as physiological variability. -* Removing invalid intervals or marking them as NaN can both be correct choices for time-domain statistics over valid intervals. -* tdmetrics does not interpolate NaN values, gap-fill intervals, or reconstruct event times internally. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `hrv.tdmetrics.invalid_dtk_inf` | [conformance/hrv/tdmetrics/invalid_dtk_inf.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/invalid_dtk_inf.json) | -| `hrv.tdmetrics.invalid_dtk_matrix` | [conformance/hrv/tdmetrics/invalid_dtk_matrix.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/invalid_dtk_matrix.json) | -| `hrv.tdmetrics.invalid_dtk_negative` | [conformance/hrv/tdmetrics/invalid_dtk_negative.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/invalid_dtk_negative.json) | -| `hrv.tdmetrics.invalid_dtk_non_numeric` | [conformance/hrv/tdmetrics/invalid_dtk_non_numeric.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/invalid_dtk_non_numeric.json) | -| `hrv.tdmetrics.invalid_dtk_zero` | [conformance/hrv/tdmetrics/invalid_dtk_zero.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/invalid_dtk_zero.json) | -| `hrv.tdmetrics.single_successive_difference` | [conformance/hrv/tdmetrics/single_successive_difference.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/single_successive_difference.json) | -| `hrv.tdmetrics.single_valid_interval` | [conformance/hrv/tdmetrics/single_valid_interval.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/single_valid_interval.json) | -| `hrv.tdmetrics.valid_dtk` | [conformance/hrv/tdmetrics/valid_dtk.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/valid_dtk.json) | -| `hrv.tdmetrics.valid_dtk_with_nan` | [conformance/hrv/tdmetrics/valid_dtk_with_nan.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/hrv/tdmetrics/valid_dtk_with_nan.json) | diff --git a/docs/generated/specifications/tools.lpd_filter.md b/docs/generated/specifications/tools.lpd_filter.md deleted file mode 100644 index cd31a05..0000000 --- a/docs/generated/specifications/tools.lpd_filter.md +++ /dev/null @@ -1,96 +0,0 @@ -# Low-pass differentiator filter design - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `tools.lpd_filter` | -| Module | `tools` | -| Source JSON | [specs/tools/lpd_filter/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/tools/lpd_filter/spec.json) | - -## Summary - -Designs a low-pass differentiating FIR filter and reports its linear-phase delay. - -This tool defines low-pass differentiator FIR filter design used in ECG and pulse-processing pipelines. Explicit-order behavior is required for conformance. Automatic-order behavior is preferred when an implementation has a reliable FIR order estimator, but implementations without one may reject omitted order with an unsupported-configuration error while still conforming to the explicit-order profile. - -## Keywords - -`FIR`, `differentiator`, `low-pass`, `filter design`, `linear phase` - -## Scientific References - -| ID | Relation | Note | -| --- | --- | --- | -| `lazaro_prv_sleep_apnea_ppg_2014` | original_method | Method provenance for the low-pass differentiator filter used in pulse-rate variability processing. | - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `sampling_frequency` | real_scalar | scalar | Hz | false | false | exclusive_minimum=0 | -| `stop_frequency` | real_scalar | scalar | Hz | false | false | exclusive_minimum=0 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `pass_frequency` | real_scalar | "stop_frequency - 0.2" | Hz | exclusive_minimum=0 | -| `order` | integer_scalar | "automatic" | sample | exclusive_minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `filter_coefficients` | real_vector | vector | 1/s | -| `delay` | real_scalar | scalar | sample | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `pass_frequency` | If pass_frequency is omitted, set pass_frequency = stop_frequency - 0.2 Hz. | | -| `frequency_constraints` | pass_frequency must be strictly less than stop_frequency, and stop_frequency must be strictly less than sampling_frequency / 2. | | -| `normalized_frequencies` | Let wPass = pass_frequency / (sampling_frequency / 2) and wStop = stop_frequency / (sampling_frequency / 2). | | -| `explicit_effective_order` | For explicit order, use effective_order = order + mod(order, 2), so odd explicit orders are rounded upward to the next even order. | | -| `automatic_effective_order` | For omitted order, automatic-order implementations estimate estimated_order using firpmord([wPass, wStop], [1, 0], [0.01, 0.1]), then use effective_order = estimated_order + mod(estimated_order, 2). Implementations without a reliable automatic FIR order estimator may reject omitted order as an unsupported configuration. | | -| `filter_coefficients` | filter_coefficients are the numerator coefficients of the canonical linear-phase low-pass differentiating FIR design equivalent to MATLAB fdesign.differentiator('n,fp,fst', effective_order, wPass, wStop) followed by design(..., 'firls'), scaled by sampling_frequency / (2*pi). | | -| `delay` | delay is effective_order / 2 samples. | | - -## Behavior - -### Nan handling - -NaN, Inf, and -Inf scalar frequencies are invalid. - -### Empty input - -Empty scalar frequency inputs are invalid. - -### Input orientation - -All inputs are scalars. filter_coefficients is a one-dimensional ordered vector. - -### Insufficient data - -Explicit-order behavior is required for conformance. Omitted-order automatic behavior is preferred when a reliable FIR order estimator is available; otherwise omitted order may be rejected with an unsupported-configuration error. - -## Informative Notes - -* When pass_frequency is omitted, use stop_frequency - 0.2 Hz. -* Explicit-order behavior is required for conformance; automatic-order behavior is preferred when supported. -* When order is supplied, round it upward to the next even integer before design. -* When order is omitted, implementations may either estimate the automatic order as specified here or reject the configuration as unsupported. -* The filter is a linear-phase low-pass differentiator; delay is one half of the effective even order. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `tools.lpd_filter.explicit_pass_frequency_order4_coefficients` | [conformance/tools/lpd_filter/explicit_pass_frequency_order4_coefficients.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/lpd_filter/explicit_pass_frequency_order4_coefficients.json) | -| `tools.lpd_filter.fs256_stop12_order4_coefficients` | [conformance/tools/lpd_filter/fs256_stop12_order4_coefficients.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/lpd_filter/fs256_stop12_order4_coefficients.json) | -| `tools.lpd_filter.invalid_pass_frequency_not_less_than_stop` | [conformance/tools/lpd_filter/invalid_pass_frequency_not_less_than_stop.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/lpd_filter/invalid_pass_frequency_not_less_than_stop.json) | -| `tools.lpd_filter.invalid_stop_frequency_at_nyquist` | [conformance/tools/lpd_filter/invalid_stop_frequency_at_nyquist.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/lpd_filter/invalid_stop_frequency_at_nyquist.json) | diff --git a/docs/generated/specifications/tools.medfilt_threshold.md b/docs/generated/specifications/tools.medfilt_threshold.md deleted file mode 100644 index e92ee40..0000000 --- a/docs/generated/specifications/tools.medfilt_threshold.md +++ /dev/null @@ -1,96 +0,0 @@ -# Median-filtered adaptive threshold - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `tools.medfilt_threshold` | -| Module | `tools` | -| Source JSON | [specs/tools/medfilt_threshold/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/tools/medfilt_threshold/spec.json) | - -## Summary - -Computes a capped adaptive threshold from a one-dimensional signal using median-filter-based local baseline estimation. - -The threshold is intended for detecting unusually large samples relative to a local median baseline. The contract accepts NaN samples and uses include-NaN median semantics: if any NaN appears in the median-filter window used for an aligned output sample, that threshold sample is NaN. - -## Keywords - -`median filter`, `adaptive threshold`, `outlier detection` - -## Scientific References - -No scientific references are listed in this specification. - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `x` | real_vector | vector | a.u. | true | false | minimum_length=2 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `window` | integer_scalar | | sample | minimum=2 | -| `factor` | real_scalar | | 1 | exclusive_minimum=0 | -| `max_threshold` | real_scalar | | a.u. | exclusive_minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `threshold` | real_vector | vector | a.u. | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `x` | x is the ordered one-dimensional signal or interval sequence from which a local adaptive threshold is computed. | | -| `effective_window` | If window is larger than the length of x, use length(x) as the effective window. Otherwise use the supplied positive integer window. This preserves effective_window >= 2 because x has minimum_length = 2 and window must be >= 2. | | -| `boundary_padding` | Let half_window = floor(effective_window / 2). Pad x by prepending the first half_window samples in reverse order and appending the last half_window samples in reverse order. | | -| `median_filtered_baseline` | Apply a median filter with length effective_window - 1 to the padded sequence, then remove the half_window padded samples from each end to recover an output aligned with x. | | -| `include_nan_median` | Use include-NaN median semantics: if any NaN appears in the median-filter window used for an aligned output sample, the corresponding median_filtered_baseline and threshold samples are NaN. | | -| `threshold` | threshold is factor times the median_filtered_baseline, with any value greater than max_threshold replaced by max_threshold. | | - -## Behavior - -### Nan handling - -NaN values in x are accepted and use include-NaN median semantics: any median-filter window containing one or more NaN samples produces NaN for the aligned threshold sample. Inf and -Inf values are invalid. - -### Empty input - -Empty and single-sample x inputs are invalid. - -### Input orientation - -Row and column vectors represent the same canonical x sequence. The threshold output is a one-dimensional ordered vector aligned sample-by-sample with x. - -### Insufficient data - -x must contain at least two samples and window = 1 is invalid. If window is larger than the signal length, the effective window is shortened to length(x), which remains valid because x has minimum_length = 2. - -## Informative Notes - -* Row and column vectors are canonicalized to the same one-dimensional ordered sequence before processing. -* The median-filter baseline uses reflected boundary padding and a median-filter length of window - 1; this makes requested even and odd window values observable and is covered by conformance cases. -* NaN input samples use include-NaN median semantics. -* x must contain at least two samples, and window must be an integer greater than or equal to 2. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `tools.medfilt_threshold.even_window_behavior` | [conformance/tools/medfilt_threshold/even_window_behavior.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/even_window_behavior.json) | -| `tools.medfilt_threshold.include_nan_window` | [conformance/tools/medfilt_threshold/include_nan_window.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/include_nan_window.json) | -| `tools.medfilt_threshold.invalid_single_sample` | [conformance/tools/medfilt_threshold/invalid_single_sample.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/invalid_single_sample.json) | -| `tools.medfilt_threshold.invalid_window_one` | [conformance/tools/medfilt_threshold/invalid_window_one.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/invalid_window_one.json) | -| `tools.medfilt_threshold.max_threshold_cap` | [conformance/tools/medfilt_threshold/max_threshold_cap.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/max_threshold_cap.json) | -| `tools.medfilt_threshold.normal_outlier` | [conformance/tools/medfilt_threshold/normal_outlier.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/normal_outlier.json) | -| `tools.medfilt_threshold.odd_window_behavior` | [conformance/tools/medfilt_threshold/odd_window_behavior.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/odd_window_behavior.json) | -| `tools.medfilt_threshold.row_vector_orientation` | [conformance/tools/medfilt_threshold/row_vector_orientation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/row_vector_orientation.json) | -| `tools.medfilt_threshold.window_larger_than_signal` | [conformance/tools/medfilt_threshold/window_larger_than_signal.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/medfilt_threshold/window_larger_than_signal.json) | diff --git a/docs/generated/specifications/tools.nan_filter.md b/docs/generated/specifications/tools.nan_filter.md deleted file mode 100644 index 6766760..0000000 --- a/docs/generated/specifications/tools.nan_filter.md +++ /dev/null @@ -1,97 +0,0 @@ -# Causal filtering with NaN-aware gap handling - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `tools.nan_filter` | -| Module | `tools` | -| Source JSON | [specs/tools/nan_filter/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/tools/nan_filter/spec.json) | - -## Summary - -Applies ordinary causal filtering while interpolating short NaN gaps and preserving long NaN gaps. - -This tool defines NaN-aware causal filtering for one-dimensional vector signals. Matrix and higher-dimensional support may exist as an implementation-specific extension, but it is not required for conformance. - -## Keywords - -`NaN`, `missing data`, `causal filter`, `gap interpolation`, `segmentation` - -## Scientific References - -No scientific references are listed in this specification. - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `numerator_coefficients` | real_vector | vector | 1 | false | false | minimum_length=1 | -| `denominator_coefficients` | real_vector | vector | 1 | false | false | minimum_length=1 | -| `signal` | real_vector | vector | a.u. | true | false | None | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `max_gap` | integer_scalar | 0 | sample | minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `filtered_signal` | real_vector | vector | a.u. | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `boundary_nan_gap` | A contiguous run of NaN samples touching the first or last sample of the current processed signal or segment. Boundary NaN gaps are always preserved as NaN and must never be linearly extrapolated. | | -| `internal_short_nan_gap` | A contiguous run of NaN samples fully bounded by finite samples whose length is less than or equal to max_gap. | | -| `preserved_nan_gap` | A long internal NaN gap or any boundary NaN gap. Preserved NaN gaps remain NaN in filtered_signal and split signal into candidate finite segments. | | -| `short_gap_interpolation` | Internal short NaN gaps are filled by linear interpolation before causal filtering. | | -| `minimum_filterable_length` | A candidate finite segment is filterable only if length(segment) is at least max(length(numerator_coefficients), length(denominator_coefficients)). | | -| `segment_filtering` | Filter each filterable candidate finite segment independently with ordinary causal filter semantics after internal short-gap interpolation. | | -| `segment_too_short` | If a candidate finite segment is shorter than minimum_filterable_length, set filtered_signal to NaN over that segment. | | -| `filtered_signal` | filtered_signal is aligned sample-by-sample with signal; preserved NaN gaps and too-short candidate segments are NaN, while internal short NaN gaps in filterable segments are represented by filtered interpolated values. | | - -## Behavior - -### Nan handling - -NaN samples in signal are classified as boundary gaps, internal short gaps, or preserved internal long gaps using max_gap. Boundary gaps are always preserved as NaN and never extrapolated. Internal short gaps are linearly interpolated before filtering. Preserved gaps and too-short candidate finite segments are NaN in the output. - -### Empty input - -Empty signal input returns an empty filtered_signal. - -### Input orientation - -Row and column vectors represent the same canonical one-dimensional signal sequence. Matrix and higher-dimensional inputs are outside the normative conformance contract. filtered_signal is a one-dimensional ordered vector aligned with signal. - -### Insufficient data - -Candidate finite segments shorter than max(length(numerator_coefficients), length(denominator_coefficients)) produce NaN output over that segment. - -## Informative Notes - -* The normative conformance contract is one-dimensional vector input. -* Matrix and higher-dimensional inputs are optional implementation-specific extensions and are not required for conformance. -* With no NaN samples, the output is equivalent to ordinary causal filter(b, a, signal). -* Only internal short gaps fully bounded by finite samples are linearly interpolated before filtering. -* Boundary gaps and long internal gaps are preserved as NaN and split the signal into candidate finite segments. -* Candidate finite segments that are too short for causal filtering produce NaN output over that segment. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `tools.nan_filter.boundary_nan_preserved` | [conformance/tools/nan_filter/boundary_nan_preserved.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filter/boundary_nan_preserved.json) | -| `tools.nan_filter.long_nan_gap_segmentation` | [conformance/tools/nan_filter/long_nan_gap_segmentation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filter/long_nan_gap_segmentation.json) | -| `tools.nan_filter.no_nan_equivalent_filter` | [conformance/tools/nan_filter/no_nan_equivalent_filter.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filter/no_nan_equivalent_filter.json) | -| `tools.nan_filter.row_vector_orientation` | [conformance/tools/nan_filter/row_vector_orientation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filter/row_vector_orientation.json) | -| `tools.nan_filter.short_nan_gap_interpolation` | [conformance/tools/nan_filter/short_nan_gap_interpolation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filter/short_nan_gap_interpolation.json) | -| `tools.nan_filter.too_short_segments_nan` | [conformance/tools/nan_filter/too_short_segments_nan.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filter/too_short_segments_nan.json) | diff --git a/docs/generated/specifications/tools.nan_filtfilt.md b/docs/generated/specifications/tools.nan_filtfilt.md deleted file mode 100644 index 769d6ea..0000000 --- a/docs/generated/specifications/tools.nan_filtfilt.md +++ /dev/null @@ -1,98 +0,0 @@ -# Zero-phase filtering with NaN-aware gap handling - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `tools.nan_filtfilt` | -| Module | `tools` | -| Source JSON | [specs/tools/nan_filtfilt/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/tools/nan_filtfilt/spec.json) | - -## Summary - -Applies ordinary zero-phase filtering while interpolating short NaN gaps and preserving long NaN gaps. - -This tool defines NaN-aware zero-phase filtering for one-dimensional vector signals. Matrix and higher-dimensional support may exist as an implementation-specific extension, but it is not required for conformance. - -## Keywords - -`NaN`, `missing data`, `zero-phase filter`, `gap interpolation`, `segmentation` - -## Scientific References - -No scientific references are listed in this specification. - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `numerator_coefficients` | real_vector | vector | 1 | false | false | minimum_length=1 | -| `denominator_coefficients` | real_vector | vector | 1 | false | false | minimum_length=1 | -| `signal` | real_vector | vector | a.u. | true | false | None | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `max_gap` | integer_scalar | 0 | sample | minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `filtered_signal` | real_vector | vector | a.u. | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `zero_phase_filtering` | For each processed segment, apply ordinary forward-backward zero-phase filtering equivalent to filtfilt with the supplied numerator and denominator coefficients. | | -| `boundary_nan_gap` | A contiguous run of NaN samples touching the first or last sample of the current processed signal or segment. Boundary NaN gaps are always preserved as NaN and must never be linearly extrapolated. | | -| `internal_short_nan_gap` | A contiguous run of NaN samples fully bounded by finite samples whose length is less than or equal to max_gap. | | -| `preserved_nan_gap` | A long internal NaN gap or any boundary NaN gap. Preserved NaN gaps remain NaN in filtered_signal and split signal into candidate finite segments. | | -| `short_gap_interpolation` | Internal short NaN gaps are filled by linear interpolation before zero-phase filtering. | | -| `minimum_filterable_length` | Let filter_order = max(length(numerator_coefficients) - 1, length(denominator_coefficients) - 1). A candidate finite segment is filterable only if length(segment) > 3 * filter_order, equivalently length(segment) >= 3 * filter_order + 1. | | -| `segment_filtering` | Zero-phase filter each filterable candidate finite segment independently after internal short-gap interpolation. | | -| `segment_too_short` | If a candidate finite segment is not longer than 3 * filter_order, set filtered_signal to NaN over that segment. | | -| `filtered_signal` | filtered_signal is aligned sample-by-sample with signal; preserved NaN gaps and too-short candidate segments are NaN, while internal short NaN gaps in filterable segments are represented by zero-phase filtered interpolated values. | | - -## Behavior - -### Nan handling - -NaN samples in signal are classified as boundary gaps, internal short gaps, or preserved internal long gaps using max_gap. Boundary gaps are always preserved as NaN and never extrapolated. Internal short gaps are linearly interpolated before zero-phase filtering. Preserved gaps and too-short candidate finite segments are NaN in the output. - -### Empty input - -Empty signal input returns an empty filtered_signal. - -### Input orientation - -Row and column vectors represent the same canonical one-dimensional signal sequence. Matrix and higher-dimensional inputs are outside the normative conformance contract. filtered_signal is a one-dimensional ordered vector aligned with signal. - -### Insufficient data - -Candidate finite segments with length(segment) <= 3 * max(length(numerator_coefficients) - 1, length(denominator_coefficients) - 1) produce NaN output over that segment. - -## Informative Notes - -* The normative conformance contract is one-dimensional vector input. -* Matrix and higher-dimensional inputs are optional implementation-specific extensions and are not required for conformance. -* With no NaN samples, the output is equivalent to ordinary zero-phase filtfilt(b, a, signal). -* Only internal short gaps fully bounded by finite samples are linearly interpolated before zero-phase filtering. -* Boundary gaps and long internal gaps are preserved as NaN and split the signal into candidate finite segments. -* Candidate finite segments that are too short for MATLAB-style filtfilt produce NaN output over that segment. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `tools.nan_filtfilt.boundary_nan_preserved` | [conformance/tools/nan_filtfilt/boundary_nan_preserved.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filtfilt/boundary_nan_preserved.json) | -| `tools.nan_filtfilt.long_nan_gap_segmentation` | [conformance/tools/nan_filtfilt/long_nan_gap_segmentation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filtfilt/long_nan_gap_segmentation.json) | -| `tools.nan_filtfilt.no_nan_equivalent_filtfilt` | [conformance/tools/nan_filtfilt/no_nan_equivalent_filtfilt.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filtfilt/no_nan_equivalent_filtfilt.json) | -| `tools.nan_filtfilt.row_vector_orientation` | [conformance/tools/nan_filtfilt/row_vector_orientation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filtfilt/row_vector_orientation.json) | -| `tools.nan_filtfilt.short_nan_gap_interpolation` | [conformance/tools/nan_filtfilt/short_nan_gap_interpolation.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filtfilt/short_nan_gap_interpolation.json) | -| `tools.nan_filtfilt.too_short_segments_nan` | [conformance/tools/nan_filtfilt/too_short_segments_nan.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/nan_filtfilt/too_short_segments_nan.json) | diff --git a/docs/generated/specifications/tools.snap_to_peak.md b/docs/generated/specifications/tools.snap_to_peak.md deleted file mode 100644 index cfd4d3f..0000000 --- a/docs/generated/specifications/tools.snap_to_peak.md +++ /dev/null @@ -1,91 +0,0 @@ -# Snap detections to local maxima - -!!! warning "Generated page" - This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## Metadata - -| Field | Value | -| --- | --- | -| Canonical specification ID | `tools.snap_to_peak` | -| Module | `tools` | -| Source JSON | [specs/tools/snap_to_peak/spec.json](https://github.com/BSICoS/biosiglib/blob/main/specs/tools/snap_to_peak/spec.json) | - -## Summary - -Refines detection sample positions by moving each detection to the maximum signal sample in a NaN-aware local search window. - -This tool defines local-maximum snapping used by ECG detection pipelines to refine approximate detections onto nearby R-wave maxima while treating NaN ECG samples as signal gaps. - -## Keywords - -`peak refinement`, `sample position`, `ECG`, `R wave`, `local maximum` - -## Scientific References - -No scientific references are listed in this specification. - -## Inputs - -| id | data_type | shape | unit | allow_nan | allow_inf | constraints | -| --- | --- | --- | --- | --- | --- | --- | -| `ecg` | real_vector | vector | a.u. | true | false | minimum_length=2 | -| `detections` | real_vector | vector | sample | true | false | exclusive_minimum=0 | - -## Parameters - -| id | data_type | default | unit | constraints | -| --- | --- | --- | --- | --- | -| `window_size` | real_scalar | 20 | sample | exclusive_minimum=0 | - -## Outputs - -| id | data_type | shape | unit | -| --- | --- | --- | --- | -| `refined_detections` | real_vector | vector | sample | - -## Normative Definitions - -| Target | Definition | Formula | -| --- | --- | --- | -| `effective_window_size` | window_size is rounded to the nearest integer number of samples before constructing search windows. | | -| `finite_ecg_segment` | A finite ECG segment is a maximal contiguous run of ecg samples that are neither NaN nor infinite. NaN samples are hard boundaries between finite ECG segments; Inf and -Inf samples are invalid inputs. | | -| `search_window` | For each finite valid detection d that falls on a finite ECG sample, search from max(1, segment_start, d - effective_window_size) through min(length(ecg), segment_end, d + effective_window_size), inclusive on the one-based sample grid, where segment_start and segment_end are the boundaries of the finite_ecg_segment containing d. | | -| `refined_detection` | The refined detection is the one-based sample position of the first maximum-valued ECG sample inside the clipped finite search_window for that detection. If the corresponding detection is NaN, or if a finite detection falls on a NaN ECG sample, the refined detection is NaN. | | - -## Behavior - -### Nan handling - -NaN values in ecg are allowed and act as hard segment boundaries. Snapping for a finite valid detection must search only inside the contiguous finite ECG segment containing that detection and must not omit NaNs in a way that crosses gaps. NaN values in detections are allowed missing detection markers and produce NaN in the corresponding refined_detections element. If a finite detection falls on a NaN ECG sample, the corresponding refined_detections element is NaN. Inf and -Inf values in either ecg or detections are invalid. - -### Empty input - -Empty ecg input is invalid. Empty detections input returns an empty refined_detections vector. - -### Input orientation - -Row and column vectors represent the same canonical ecg and detections sequences. refined_detections is a one-dimensional ordered vector aligned with detections. - -### Insufficient data - -Finite detection positions less than 1 or greater than length(ecg) are invalid. Search windows near signal boundaries or NaN ECG gaps are clipped to valid finite ECG sample positions. - -## Informative Notes - -* Canonical detection positions are one-based sample positions, matching existing Biosiglib r_wave_samples fixtures and the public sample-coordinate convention. -* NaN values are accepted in ecg as hard signal-gap boundaries and in detections as missing detection markers. -* Inf and -Inf values in ecg or detections are invalid. - -## Conformance Cases - -| Case ID | File | -| --- | --- | -| `tools.snap_to_peak.boundary_clipping` | [conformance/tools/snap_to_peak/boundary_clipping.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/snap_to_peak/boundary_clipping.json) | -| `tools.snap_to_peak.configurable_window_large` | [conformance/tools/snap_to_peak/configurable_window_large.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/snap_to_peak/configurable_window_large.json) | -| `tools.snap_to_peak.configurable_window_small` | [conformance/tools/snap_to_peak/configurable_window_small.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/snap_to_peak/configurable_window_small.json) | -| `tools.snap_to_peak.detection_nan_returns_nan` | [conformance/tools/snap_to_peak/detection_nan_returns_nan.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/snap_to_peak/detection_nan_returns_nan.json) | -| `tools.snap_to_peak.detection_on_nan_ecg_returns_nan` | [conformance/tools/snap_to_peak/detection_on_nan_ecg_returns_nan.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/snap_to_peak/detection_on_nan_ecg_returns_nan.json) | -| `tools.snap_to_peak.ecg_nan_segment_boundary` | [conformance/tools/snap_to_peak/ecg_nan_segment_boundary.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/snap_to_peak/ecg_nan_segment_boundary.json) | -| `tools.snap_to_peak.invalid_detection_out_of_bounds` | [conformance/tools/snap_to_peak/invalid_detection_out_of_bounds.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/snap_to_peak/invalid_detection_out_of_bounds.json) | -| `tools.snap_to_peak.local_maxima` | [conformance/tools/snap_to_peak/local_maxima.json](https://github.com/BSICoS/biosiglib/blob/main/conformance/tools/snap_to_peak/local_maxima.json) | diff --git a/docs/implementations.md b/docs/implementations.md new file mode 100644 index 0000000..6427dd5 --- /dev/null +++ b/docs/implementations.md @@ -0,0 +1,15 @@ +# Implementations + +Biosiglib defines shared method contracts; the executable libraries provide language-specific APIs. + +## Python + +[Biosigpy](https://github.com/BSICoS/biosigpy) provides the Python implementation, installation instructions, and executable examples. + +## MATLAB + +[Biosigmat](https://github.com/BSICoS/biosigmat) provides the MATLAB implementation, installation instructions, and executable examples. + +Both libraries may use conventions that are natural to their language while preserving the same inputs, outputs, defaults, numerical meaning, and edge-case behavior. Each implementation pins the exact Biosiglib revision that its full test suite validates. + +Start with the [method catalog](methods/index.md), then follow the source link for the language you use. diff --git a/docs/index.md b/docs/index.md index ec6a3cf..48c7c1f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,29 +1,23 @@ # Biosiglib -Biosiglib is the language-independent source of truth for the Biosiglib ecosystem. It is not an executable signal-processing library, and it does not provide user-facing MATLAB or Python functions. +Biosiglib describes biomedical signal-processing methods and provides shared, machine-readable contracts for their Python and MATLAB implementations. -Instead, Biosiglib defines the shared contract that implementations use: +Use the [method catalog](methods/index.md) to understand what each function does, which inputs it expects, what it returns, and the scientific assumptions behind it. Each method page also links to: -* machine-readable JSON specifications for public algorithm behavior; -* shared fixtures and metadata; -* conformance cases and expected outputs; -* validation resources for specifications, fixtures, references, and implementation manifests. +- the Python and MATLAB source code; +- its normative JSON contract; +- its validation cases; +- the relevant scientific references. -The language-specific libraries implement those contracts: +## Run the methods -* [Biosigmat](https://github.com/BSICoS/biosigmat) is the MATLAB implementation. -* [Biosigpy](https://github.com/BSICoS/biosigpy) is the Python implementation. +Biosiglib is not an executable package. Choose the implementation for your environment: -## What Lives Here +- [Biosigpy](https://github.com/BSICoS/biosigpy) for Python; +- [Biosigmat](https://github.com/BSICoS/biosigmat) for MATLAB. -Biosiglib describes scientific and computational behavior that should remain consistent across programming languages. It records canonical inputs, outputs, units, parameters, defaults, edge-case behavior, numerical comparison rules, fixtures, conformance cases, and scientific provenance. +See [Implementations](implementations.md) for installation links and the relationship between the two libraries. -The repository is designed so humans can read the behavior while tools can validate it. The JSON files are the normative source. This website is the readable view. +## Technical contract -## Current Scope - -The current documentation covers: - -See the generated [specification catalog](specifications.md) for the complete authoritative inventory of current contracts. - -The initial pilots established the specification, fixture, conformance, and release patterns before the full Biosiglib scope expands across ECG, PPG, respiration, HRV, and other biomedical signal-processing tools. +The public pages explain how to use and interpret each method. The JSON specifications and shared validation cases define the exact cross-language behavior. Contributors can access both directly from every method page or browse them in the [Biosiglib repository](https://github.com/BSICoS/biosiglib). diff --git a/docs/methods/descriptions.json b/docs/methods/descriptions.json new file mode 100644 index 0000000..51358c3 --- /dev/null +++ b/docs/methods/descriptions.json @@ -0,0 +1,121 @@ +{ + "ecg.baselineremove": { + "ecg": "Sampled ECG signal from which the slowly varying baseline will be removed.", + "fiducial_positions": "Sample positions expected to represent a local isoelectric ECG level.", + "offset": "Number of samples subtracted from every fiducial before estimating its local level.", + "window_size": "Requested local averaging span around each adjusted fiducial.", + "ecg_detrended": "ECG after subtracting the estimated baseline.", + "baseline": "Estimated baseline aligned sample by sample with the input ECG." + }, + "ecg.pantompkins": { + "ecg": "Sampled ECG signal in which R waves will be detected.", + "sampling_frequency": "Sampling frequency of the ECG signal.", + "bandpass_frequency": "Lower and upper cutoff frequencies of the detection band-pass filter.", + "integration_window_size": "Duration of the moving integration window used by the detector.", + "minimum_peak_distance": "Minimum accepted temporal separation between candidate R waves.", + "snap_to_peak_window_size": "Local search radius used to refine each detection on the ECG.", + "r_wave_times": "Detected R-wave occurrence times in ascending order.", + "ecg_filtered": "Band-pass-filtered ECG used by the detection chain.", + "decg_squared": "Squared derivative signal used to emphasize rapid QRS changes.", + "decg_envelope": "Integrated detection envelope used to locate candidate beats." + }, + "ecg.sloperange": { + "decg": "Sampled derivative ECG signal.", + "r_wave_times": "R-wave occurrence times aligned with the derivative ECG.", + "sampling_frequency": "Sampling frequency of the derivative ECG.", + "edr": "Beat-to-beat ECG-derived respiration amplitude series.", + "upslopes": "Signal-aligned local upslope traces used for inspection.", + "downslopes": "Signal-aligned local downslope traces used for inspection.", + "upslope_max_positions": "Selected maximum-upslope position for each processed beat.", + "downslope_min_positions": "Selected minimum-downslope position for each processed beat." + }, + "hrv.fdmetrics": { + "pxx": "Power spectral density used by the conventional LF/HF mode.", + "related_pxx": "Spectrum of the HRV component linearly related to respiration.", + "unrelated_pxx": "Spectrum of the residual HRV component not represented by respiration.", + "f": "Strictly increasing frequency grid shared by the supplied spectrum or spectra.", + "limit_hf": "Whether conventional HF integration stops at the represented 0.4 Hz boundary.", + "hf": "Integrated conventional high-frequency power.", + "lf": "Integrated conventional low-frequency power.", + "lfn": "Low-frequency power normalized by the combined LF and HF power.", + "lfhf": "Ratio between conventional low-frequency and high-frequency power.", + "urlf": "Respiration-unrelated low-frequency power.", + "re": "Respiration-related power over the represented analysis range.", + "r": "Normalized respiration-unrelated LF index." + }, + "hrv.fillgaps": { + "tk": "Strictly increasing event occurrence times after false-positive removal.", + "gap_detection_factor": "Multiplier that marks an observed interval as locally long.", + "correction_upper_factor": "Upper acceptance bound for reconstructed intervals.", + "correction_lower_factor": "Lower acceptance bound used while choosing insertion counts.", + "minimum_interval": "Smallest physiologically accepted reconstructed interval.", + "max_gap_duration": "Longest gap duration that the method will attempt to reconstruct.", + "tn": "Event occurrence times including accepted reconstructed events.", + "dtn": "Intervals between output events, with unresolved spans kept explicit." + }, + "hrv.ipfm": { + "tn": "Strictly increasing beat or pulse occurrence times.", + "fs": "Sampling frequency of the uniform output grid.", + "spline_order": "Order of the B-spline used to interpolate cumulative event count.", + "ihr": "Uniformly sampled instantaneous heart rate.", + "m": "Optional dimensionless TVIPFM modulation relative to the time-varying mean rate." + }, + "hrv.osp": { + "m": "Uniformly sampled HRV modulation signal to be decomposed.", + "resp": "Respiratory signal sampled on the same grid and time origin as the HRV modulation.", + "resp_pxx": "Respiratory power spectrum used to select a dominant respiratory frequency.", + "f": "Frequency grid associated with the respiratory spectrum.", + "fs": "Common sampling frequency of the HRV modulation and respiratory signals.", + "min_resp_frequency": "Lower bound applied to the selected respiratory frequency.", + "m_resp": "HRV component represented by the delayed-respiration subspace.", + "m_unrelated": "Residual HRV component outside the selected linear respiratory subspace.", + "delay": "Adaptive number of respiratory regressors and first aligned output sample." + }, + "hrv.removefp": { + "tk": "Strictly increasing event occurrence times that may contain false-positive detections.", + "tn": "Event occurrence times after removing detections that follow abnormally short intervals." + }, + "hrv.tdmetrics": { + "dtk": "Clean beat-to-beat or pulse-to-pulse intervals.", + "mhr": "Mean heart or pulse rate computed from valid intervals.", + "sdnn": "Sample standard deviation of valid intervals.", + "sdsd": "Sample standard deviation of successive valid interval differences.", + "rmssd": "Root mean square of successive valid interval differences.", + "pnn50": "Percentage of successive valid interval differences greater than 50 ms." + }, + "tools.lpd_filter": { + "sampling_frequency": "Sampling frequency for which the differentiating filter is designed.", + "stop_frequency": "Frequency at which the low-pass differentiating response reaches its stop band.", + "pass_frequency": "Optional end of the differentiating pass band.", + "order": "Even FIR filter order.", + "filter_coefficients": "FIR numerator coefficients of the designed differentiating filter.", + "delay": "Constant linear-phase delay introduced by the FIR filter." + }, + "tools.medfilt_threshold": { + "x": "One-dimensional signal for which an adaptive threshold is required.", + "window": "Local median-filter window setting.", + "factor": "Multiplier applied to the local median baseline.", + "max_threshold": "Upper cap applied to the adaptive threshold.", + "threshold": "Sample-aligned capped adaptive threshold." + }, + "tools.nan_filter": { + "numerator_coefficients": "Numerator coefficients of the causal digital filter.", + "denominator_coefficients": "Denominator coefficients of the causal digital filter.", + "signal": "Signal to filter, optionally containing missing-value gaps.", + "max_gap": "Largest internal NaN gap that will be interpolated before filtering.", + "filtered_signal": "Causally filtered signal with long missing spans preserved." + }, + "tools.nan_filtfilt": { + "numerator_coefficients": "Numerator coefficients of the digital filter.", + "denominator_coefficients": "Denominator coefficients of the digital filter.", + "signal": "Signal to filter, optionally containing missing-value gaps.", + "max_gap": "Largest internal NaN gap that will be interpolated before filtering.", + "filtered_signal": "Zero-phase filtered signal with long missing spans preserved." + }, + "tools.snap_to_peak": { + "ecg": "Sampled ECG signal used to refine candidate detections.", + "detections": "Candidate sample positions to move onto nearby ECG maxima.", + "window_size": "Search radius around each candidate detection.", + "refined_detections": "Detection positions moved to the selected local ECG maxima." + } +} diff --git a/docs/methods/ecg.baselineremove.md b/docs/methods/ecg.baselineremove.md new file mode 100644 index 0000000..c3544a4 --- /dev/null +++ b/docs/methods/ecg.baselineremove.md @@ -0,0 +1,28 @@ +--- +spec_id: ecg.baselineremove +title: ECG baseline removal from fiducial isoelectric samples +--- + +# ECG baseline removal from fiducial isoelectric samples + +## What it does + +This method estimates slow ECG baseline drift from supplied fiducial positions expected to represent the local isoelectric level. It subtracts a smooth interpolation of those levels and also returns the estimated baseline. + +## When to use it + +Use it when suitable isoelectric fiducials are already available and low-frequency baseline motion is obscuring ECG morphology or amplitude measurements. It is not a fiducial detector and does not verify that the supplied positions are physiologically appropriate. + + + +## How it works + +Each fiducial is shifted by `offset`, normalized to the sample grid, and represented by the mean ECG level in a short local window. Two or more valid levels define a smooth baseline over the complete signal: linear with two levels, quadratic with three, and not-a-knot cubic with four or more. + +The offset, averaging window, and boundary behavior are empirical algorithm choices rather than constants established by the original spline-baseline literature. + +## Interpretation and limitations + +Poorly placed fiducials can remove genuine ECG morphology or create misleading extrapolation near the signal boundaries. Fiducials should cover the analyzed segment and should be reviewed in the context of the downstream measurement. + + diff --git a/docs/methods/ecg.pantompkins.md b/docs/methods/ecg.pantompkins.md new file mode 100644 index 0000000..09a4515 --- /dev/null +++ b/docs/methods/ecg.pantompkins.md @@ -0,0 +1,26 @@ +--- +spec_id: ecg.pantompkins +title: Pan-Tompkins-style ECG R-wave detection +--- + +# Pan-Tompkins-style ECG R-wave detection + +## What it does + +This detector locates ordered R-wave occurrence times in a sampled ECG signal. It can also return the filtered ECG, squared derivative, and integrated envelope used to inspect detections. + +## When to use it + +Use it for conventional QRS-oriented R-wave detection when the ECG sampling frequency is known. The intermediate signals are useful for checking why a beat was accepted or missed. + + + +## How it works + +Finite ECG segments pass through band-pass filtering, derivative filtering, squaring, moving-window integration, peak detection, and local peak refinement. NaN samples separate independent finite segments so filtering and detection never cross a missing-data gap. + +## Interpretation and limitations + +The detector follows the Pan-Tompkins processing approach but is not an exact reproduction of the original real-time algorithm. Noise, atypical QRS morphology, poor parameter choices, or records shorter than the required processing context can reduce detection reliability. + + diff --git a/docs/methods/ecg.sloperange.md b/docs/methods/ecg.sloperange.md new file mode 100644 index 0000000..9191ca4 --- /dev/null +++ b/docs/methods/ecg.sloperange.md @@ -0,0 +1,26 @@ +--- +spec_id: ecg.sloperange +title: Slope-range ECG-derived respiration +--- + +# Slope-range ECG-derived respiration + +## What it does + +Slope-range ECG-derived respiration estimates a beat-to-beat respiratory modulation signal from derivative ECG morphology around detected R waves. + +## When to use it + +Use it when a respiratory signal is unavailable but reliable R-wave timing and a derivative ECG are available. It provides a relative respiration surrogate, not a measurement in physical respiratory units. + + + +## How it works + +For each R wave, the method compares the strongest local upslope with the strongest local downslope. Their difference forms the EDR amplitude. Signal-aligned slope traces and selected extrema positions are returned for visual inspection. + +## Interpretation and limitations + +Interpret trends rather than absolute amplitudes. Incorrect R-wave detections, unstable QRS morphology, noise, or incomplete boundary windows can make the surrogate unreliable. + + diff --git a/docs/methods/hrv.fdmetrics.md b/docs/methods/hrv.fdmetrics.md new file mode 100644 index 0000000..97eeea2 --- /dev/null +++ b/docs/methods/hrv.fdmetrics.md @@ -0,0 +1,26 @@ +--- +spec_id: hrv.fdmetrics +title: Frequency-domain HRV metrics +--- + +# Frequency-domain HRV metrics + +## What it does + +This method integrates conventional LF and HF powers or, alternatively, respiration-related and respiration-unrelated spectra obtained after orthogonal subspace projection. + +## When to use it + +Use conventional mode to summarize an HRV spectrum in standard frequency bands. Use respiration-separated mode only when the supplied spectra represent the related and residual components of a compatible OSP analysis. + + + +## How it works + +Band samples are selected directly from the supplied frequency grid and integrated without interpolating new spectral points. The represented grid therefore determines the effective band coverage. Respiration-separated mode reports unrelated LF power, related power, and a bounded normalized index. + +## Interpretation and limitations + +LF and HF powers are descriptive and should not be treated as pure sympathetic or parasympathetic measures. Spectral estimation, detrending, record length, stationarity, artifacts, and respiratory conditions can dominate interpretation. Partial frequency coverage should be reported explicitly. + + diff --git a/docs/methods/hrv.fillgaps.md b/docs/methods/hrv.fillgaps.md new file mode 100644 index 0000000..cff1715 --- /dev/null +++ b/docs/methods/hrv.fillgaps.md @@ -0,0 +1,26 @@ +--- +spec_id: hrv.fillgaps +title: Missing-event gap filling +--- + +# Missing-event gap filling + +## What it does + +This method reconstructs plausible event timestamps inside abnormally long intervals before interval-based HRV or pulse-rate variability analysis. + +## When to use it + +Use it after false-positive detections have been removed and when missed beats or pulses have merged several physiological intervals into one long observed interval. + + + +## How it works + +Locally long intervals are detected against a median-based adaptive baseline. The method tries increasing insertion counts and uses shape-preserving interpolation from surrounding valid intervals. Reconstructions must remain inside local acceptance bounds and preserve the duration between observed events. + +## Interpretation and limitations + +Inserted timestamps are deterministic estimates, not observed events. Abrupt rhythm changes, insufficient context, or long missing spans may remain unresolved; those spans stay explicit in the interval output. + + diff --git a/docs/methods/hrv.ipfm.md b/docs/methods/hrv.ipfm.md new file mode 100644 index 0000000..412eae1 --- /dev/null +++ b/docs/methods/hrv.ipfm.md @@ -0,0 +1,28 @@ +--- +spec_id: hrv.ipfm +title: IPFM heart-timing reconstruction and TVIPFM modulation +--- + +# IPFM heart-timing reconstruction and TVIPFM modulation + +## What it does + +This method reconstructs uniformly sampled instantaneous heart rate from discrete beat or pulse occurrence times. It can also estimate dimensionless TVIPFM modulation relative to a slowly changing mean rate. + +## When to use it + +Use it when an interval sequence must be converted into a uniformly sampled heart-rate or modulation signal for spectral or multivariate analysis. + + + +## How it works + +A high-order B-spline interpolates cumulative event count after adding virtual boundary events for numerical stability. Its derivative gives instantaneous rate on the requested uniform grid. When modulation is requested, a zero-phase low-pass estimate of mean rate is removed and used to normalize the residual. + +The default spline order and boundary extension are empirical numerical choices. The 0.03 Hz separation used by TVIPFM is supported by the cited method literature. + +## Interpretation and limitations + +Event times must already be ordered, finite, and physiologically meaningful. High-order interpolation can overshoot on pathological timing patterns. The modulation output is model-based and should not be interpreted as a direct autonomic measurement. + + diff --git a/docs/methods/hrv.osp.md b/docs/methods/hrv.osp.md new file mode 100644 index 0000000..48dd872 --- /dev/null +++ b/docs/methods/hrv.osp.md @@ -0,0 +1,26 @@ +--- +spec_id: hrv.osp +title: Respiratory decomposition by orthogonal subspace projection +--- + +# Respiratory decomposition by orthogonal subspace projection + +## What it does + +Orthogonal subspace projection separates uniformly sampled HRV modulation into a component represented by respiration and delayed copies of respiration, plus a residual outside that linear subspace. + +## When to use it + +Use it to quantify or remove linear respiratory association when HRV modulation and respiration are synchronized on the same uniform sampling grid. + + + +## How it works + +A dominant respiratory frequency sets an adaptive delayed-respiration model spanning approximately two cycles. HRV is projected onto that subspace, and the residual is obtained by subtraction. The dominant-frequency selection and minimum-frequency floor are empirical algorithm choices. + +## Interpretation and limitations + +The related component measures linear association, not causal respiratory influence. Nonlinear effects, synchronization errors, artifacts, poor respiratory measurements, and unrelated dynamics can remain in the residual. + + diff --git a/docs/methods/hrv.removefp.md b/docs/methods/hrv.removefp.md new file mode 100644 index 0000000..423003b --- /dev/null +++ b/docs/methods/hrv.removefp.md @@ -0,0 +1,26 @@ +--- +spec_id: hrv.removefp +title: False-positive event removal +--- + +# False-positive event removal + +## What it does + +This method removes event detections that follow abnormally short event-to-event intervals. + +## When to use it + +Use it before gap filling or interval-based variability analysis when an event detector may have inserted extra beats or pulses. It is intended for strictly ordered event times. + + + +## How it works + +Each interval is compared with a local median-based baseline. A detection after a sufficiently short interval is removed, and the surrounding interval structure is reevaluated deterministically. + +## Interpretation and limitations + +The threshold is an empirical preprocessing rule, not a clinical classifier. Genuine short intervals may be removed when the local rhythm changes abruptly or contains arrhythmia. + + diff --git a/docs/methods/hrv.tdmetrics.md b/docs/methods/hrv.tdmetrics.md new file mode 100644 index 0000000..b014844 --- /dev/null +++ b/docs/methods/hrv.tdmetrics.md @@ -0,0 +1,26 @@ +--- +spec_id: hrv.tdmetrics +title: Time-domain HRV metrics +--- + +# Time-domain HRV metrics + +## What it does + +This method computes standard time-domain variability metrics from cleaned beat-to-beat or pulse-to-pulse intervals. + +## When to use it + +Use it after event detection and interval cleaning. The input may contain NaN markers for intervals that should be omitted, but valid intervals must be positive and expressed in seconds. + + + +## How it works + +Mean rate and SDNN use all valid intervals. SDSD, RMSSD, and pNN50 use successive differences only when both adjacent intervals are valid. The sample standard-deviation convention is used where applicable. + +## Interpretation and limitations + +Results depend strongly on recording duration, preprocessing, missing data, activity, posture, and physiological context. Metrics from different protocols should not be compared without accounting for those factors. + + diff --git a/docs/methods/index.md b/docs/methods/index.md new file mode 100644 index 0000000..59895c0 --- /dev/null +++ b/docs/methods/index.md @@ -0,0 +1,5 @@ +# Methods + +This section explains what each method does, what data it expects, what it returns, and the main limitations that affect interpretation. Biosiglib defines the shared behavior; follow the implementation links on each page to use the method in Python or MATLAB. + + diff --git a/docs/methods/tools.lpd_filter.md b/docs/methods/tools.lpd_filter.md new file mode 100644 index 0000000..1e5ffdb --- /dev/null +++ b/docs/methods/tools.lpd_filter.md @@ -0,0 +1,26 @@ +--- +spec_id: tools.lpd_filter +title: Low-pass differentiating FIR filter design +--- + +# Low-pass differentiating FIR filter design + +## What it does + +This utility designs a linear-phase FIR filter that differentiates low-frequency signal content while attenuating higher frequencies. + +## When to use it + +Use it when a processing chain requires a reproducible low-pass differentiator with an explicit constant delay. + + + +## How it works + +The requested sampling, pass, and stop frequencies define an even-order antisymmetric FIR response. The returned delay can be used to align the filtered signal with the original samples. + +## Interpretation and limitations + +The coefficients are tied to the requested sampling frequency and should be redesigned when it changes. The delay must be handled explicitly in causal processing chains. + + diff --git a/docs/methods/tools.medfilt_threshold.md b/docs/methods/tools.medfilt_threshold.md new file mode 100644 index 0000000..64751ac --- /dev/null +++ b/docs/methods/tools.medfilt_threshold.md @@ -0,0 +1,22 @@ +--- +spec_id: tools.medfilt_threshold +title: Median-filter adaptive threshold +--- + +# Median-filter adaptive threshold + +## What it does + +This utility produces a sample-aligned adaptive threshold from a one-dimensional signal using a local median baseline, a multiplier, and an upper cap. + +## When to use it + +Use it when a detector needs a robust local threshold that follows slow baseline changes without being dominated by isolated large samples. + + + +## Interpretation and limitations + +The result depends on window length and boundary handling. The factor and cap are algorithm settings rather than universal physiological thresholds. + + diff --git a/docs/methods/tools.nan_filter.md b/docs/methods/tools.nan_filter.md new file mode 100644 index 0000000..9ca8260 --- /dev/null +++ b/docs/methods/tools.nan_filter.md @@ -0,0 +1,26 @@ +--- +spec_id: tools.nan_filter +title: NaN-aware causal filtering +--- + +# NaN-aware causal filtering + +## What it does + +This utility applies an ordinary causal digital filter while interpolating short internal NaN gaps and preserving long missing spans. + +## When to use it + +Use it when filtering must remain causal and short missing gaps may be bridged without joining independent signal segments across longer gaps. + + + +## How it works + +Short internal gaps are interpolated before filtering. Long gaps split the signal into independent finite segments, and missing boundary samples remain missing. + +## Interpretation and limitations + +Interpolated samples are estimates. `max_gap` should reflect the sampling frequency and the longest absence that can reasonably be bridged for the intended analysis. + + diff --git a/docs/methods/tools.nan_filtfilt.md b/docs/methods/tools.nan_filtfilt.md new file mode 100644 index 0000000..8ade577 --- /dev/null +++ b/docs/methods/tools.nan_filtfilt.md @@ -0,0 +1,26 @@ +--- +spec_id: tools.nan_filtfilt +title: NaN-aware zero-phase filtering +--- + +# NaN-aware zero-phase filtering + +## What it does + +This utility applies forward-backward zero-phase filtering while interpolating short internal NaN gaps and preserving long missing spans. + +## When to use it + +Use it for offline processing when phase preservation matters and short missing gaps may be bridged. It is not suitable for real-time causal processing. + + + +## How it works + +Short internal gaps are interpolated before filtering. Long gaps split the signal into independent finite segments. Each sufficiently long segment is filtered forward and backward without using samples across a missing span. + +## Interpretation and limitations + +Short segments may be impossible to filter with the requested coefficients. Interpolation and forward-backward edge handling can affect samples near gaps and segment boundaries. + + diff --git a/docs/methods/tools.snap_to_peak.md b/docs/methods/tools.snap_to_peak.md new file mode 100644 index 0000000..e3f7535 --- /dev/null +++ b/docs/methods/tools.snap_to_peak.md @@ -0,0 +1,22 @@ +--- +spec_id: tools.snap_to_peak +title: Detection refinement to local ECG peaks +--- + +# Detection refinement to local ECG peaks + +## What it does + +This utility moves each candidate detection to the maximum ECG sample inside a local search window. + +## When to use it + +Use it to refine approximate ECG detections after a detector has identified the correct neighborhood but not the exact local peak sample. + + + +## Interpretation and limitations + +The method assumes the desired fiducial is the local maximum. Wide windows can jump to a neighboring wave, while narrow windows may not reach the intended peak. Missing samples inside the search region are ignored according to the defined NaN behavior. + + diff --git a/docs/releases.md b/docs/releases.md deleted file mode 100644 index 8c8d261..0000000 --- a/docs/releases.md +++ /dev/null @@ -1,69 +0,0 @@ -# Releases - -Biosiglib uses independent semantic versioning with `MAJOR.MINOR.PATCH`. Biosigmat and Biosigpy also use their own independent versions. Implementation versions do not mirror the Biosiglib version: each implementation declares conformance with one exact Biosiglib commit. - -## Release Semantics - -A Biosiglib release captures the current language-independent source of truth: - -* JSON specifications; -* schemas; -* fixture catalogs and fixture files; -* conformance cases and expected outputs; -* validation tooling; -* coordinated conformance metadata. - -Classify the complete change set since the latest release. When several changes have different impacts, use the highest required increment. - -## Classification Checklist - -| Increment | Use when | Typical downstream impact | -| --- | --- | --- | -| **MAJOR** | A released normative contract changes incompatibly. This includes required input or output changes, removals or renames, formulas, units, defaults, validation, `NaN` handling, edge cases, output semantics, or incompatible schema and validator changes. It also includes a correction that deliberately replaces released normative behavior. | Previously conformant implementations must adapt before the Biosiglib release is published. | -| **MINOR** | The source of truth gains a compatible capability: a new specification, fixture, conformance case, optional schema field, or compatible validator rule. A new case may expose a latent implementation defect even when the normative behavior itself is unchanged. | Existing public behavior remains valid, but both implementations must pass the expanded complete suite before release. | -| **PATCH** | Normative behavior is unchanged. Examples include informative-only documentation and tooling corrections, provenance metadata corrections, and expected-value corrections that restore an already unambiguous released definition. | No intentional public algorithm change. Downstream metadata or tests may still need a small update. | - -Apply these checks before choosing an increment: - -1. List every specification, fixture, case, schema, validator, scientific-provenance, tooling, and documentation change since the latest tag. -2. Identify whether each change affects normative behavior, compatible conformance coverage, or informative material only. -3. Check whether any previously conformant implementation or valid machine-readable artifact would become non-conformant. -4. Check corrections to released expected values against the normative definition and scientific provenance. Use PATCH only when the correction restores an unambiguous existing contract; use MAJOR if the contract itself changes. -5. Route ambiguous scientific changes to explicit maintainer review. Do not infer a release class automatically when formulas, signal-processing direction, phase, units, physiological meaning, missing-value behavior, or provenance leave more than one defensible interpretation. -6. Record the selected increment and reasoning in the pull request and changelog. - -## Examples From Project History - -* **Compatible new specification — MINOR:** v0.3.0 added the new `ecg.sloperange` specification and its first shared cases without replacing an existing released contract. -* **Conformance case exposes an implementation defect — classify the contract, not the failure:** the v0.6.0 `hrv.tdmetrics` single-interval cases exposed defects tracked by [Biosigpy #37](https://github.com/BSICoS/biosigpy/issues/37) and [Biosigmat #48](https://github.com/BSICoS/biosigmat/issues/48). A new case against unchanged released behavior is MINOR, but those cases accompanied a normative minimum-data change, so an equivalent future Biosiglib release would use the higher MAJOR classification. Each downstream fix remains independently versioned. -* **Normative correction requiring downstream changes — MAJOR:** issue #59 made four previously informative `ecg.sloperange` diagnostics required outputs and defined their indexing, boundary, and tie semantics. Downstream implementations must adapt before claiming conformance. -* **Released expected-value correction — PATCH:** v0.5.4 corrected the `tools.nan_filtfilt` long-gap fixture to match independent filtering and the already defined segment semantics. -* **Informative-only correction — PATCH:** an isolated correction to generated-document navigation, explanatory prose, or a citation that does not change normative JSON is a patch. - -## Release Readiness Checklist - -Before tagging a Biosiglib release: - -1. Replace the changelog's `Unreleased` heading with the selected version and release date, and describe breaking or adaptation-requiring changes explicitly. -2. Regenerate specification pages and pass documentation checks, specification and fixture validation, validator tests, compile checks, strict MkDocs build, and `git diff --check`. -3. Confirm the release is created from the intended commit on the default branch and that the version tag resolves to that exact commit. -4. Verify Biosigmat and Biosigpy have merged their adaptations and complete conformance suites for the release target commit. -5. Verify both downstream manifests pin that exact commit. The release workflow enforces this invariant before tagging. -6. Prepare release notes that state the classification, normative impact, exact commit, and downstream readiness. - -## Coordinated Conformance - -The expected path is: - -1. Merge the reviewed Biosiglib contract commit without releasing it yet. -2. Update Biosigmat and Biosigpy to that commit, include any required implementation work, and pass their complete suites. -3. Merge both downstream conformance declarations. -4. Release the exact Biosiglib commit after the automated downstream-pin gate passes. - -For changes that require no implementation adaptation, each downstream repository provides an `Update Biosiglib Pin` workflow that validates the full suite before opening a ready-for-review pull request. Changes requiring implementation work use an ordinary feature or fix pull request that updates the pin together with the code. - -A pin update is a total conformance declaration, not a roadmap entry. It cannot merge while any specification or shared case in the pinned commit remains unsupported. - -## Documentation Publication - -The documentation workflow builds the MkDocs site on pull requests. On pushes to `main`, it uploads the built site for GitHub Pages deployment. Repository settings may still need GitHub Pages enabled with "GitHub Actions" selected as the build and deployment source before the first publication succeeds. diff --git a/docs/scientific/ecg.baselineremove.md b/docs/scientific/ecg.baselineremove.md deleted file mode 100644 index 58595fb..0000000 --- a/docs/scientific/ecg.baselineremove.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -spec_id: ecg.baselineremove -title: ECG baseline removal from fiducial isoelectric samples ---- - -# ECG baseline removal from fiducial isoelectric samples - -## Purpose - -Slow baseline drift can obscure ECG morphology and distort amplitude measurements without behaving like the cardiac activity of interest. This method estimates that drift from supplied fiducial positions expected to represent the local isoelectric level, then subtracts the resulting smooth baseline from the ECG. - -## Scientific rationale - -Meyer and Keiser describe estimating baseline noise from samples in the PR segment and reconstructing a continuous baseline with cubic splines. The approach uses physiologically selected locations rather than assuming that a generic frequency cutoff can always separate baseline motion from diagnostically relevant ECG content. - -The fiducial positions remain an external input to this contract. Biosiglib does not define how they are detected or guarantee that they fall in a genuinely isoelectric interval. Poor fiducials can therefore produce a numerically conformant but scientifically misleading correction. - -## Local level estimation - -Biosiglib preserves the mature Biosigmat behavior of shifting each position by a caller-supplied sample offset and averaging ECG values in a short symmetric neighborhood. That offset, the default window, expansion of an even requested window to an odd span, and truncation at signal boundaries are empirical compatibility choices. They are not constants or boundary rules established by Meyer and Keiser. - -Unordered, repeated, and fractional fiducial positions are normalized before local levels are calculated. These rules make the numerical result reproducible across languages while keeping the public API free to use its native array representation. - -## Baseline interpolation - -With enough local levels, the baseline is evaluated over the complete ECG sample grid. Four or more points use the not-a-knot cubic spline behavior of the established MATLAB implementation. Two and three points use its linear and quadratic reduced-degree behavior. Evaluation outside the first and last valid fiducials continues the corresponding end polynomial rather than clamping the baseline. - -This extrapolation can grow rapidly when endpoint fiducials poorly constrain the polynomial. Supplying fiducials that cover the analyzed segment is therefore preferable even though the contract defines outlying samples for compatibility. - -## Assumptions and limitations - -The ECG must be finite and real, and the supplied fiducials must use the expected sample grid. Baseline removal changes amplitudes and can remove genuine low-frequency morphology when fiducials are misplaced or when the ECG does not have a stable local isoelectric reference. - -The method is a deterministic preprocessing operation, not a detector, quality measure, or physiological interpretation. Its output should be reviewed in the context of the fiducial source and intended downstream analysis. - -## References - -The use of PR-segment baseline estimates and cubic-spline reconstruction is described by Meyer and Keiser (1977). The exact normalization, local averaging, fallback, and boundary rules are Biosigmat compatibility behavior defined by the normative contract. - -## Specification - -The normative contract is the generated [`ecg.baselineremove` specification](../generated/specifications/ecg.baselineremove.md). diff --git a/docs/scientific/ecg.sloperange.md b/docs/scientific/ecg.sloperange.md deleted file mode 100644 index 2072d2c..0000000 --- a/docs/scientific/ecg.sloperange.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -spec_id: ecg.sloperange -title: Slope-range ECG-derived respiration ---- - -# Slope-range ECG-derived respiration - -## Purpose - -Slope-range ECG-derived respiration estimates a beat-to-beat respiratory modulation signal from ECG morphology. It is useful when a respiratory belt or airflow signal is unavailable but reliable ECG R-wave timing and derivative morphology are available. - -## Scientific rationale - -Respiration changes the position of the heart and the electrical axis seen by a single ECG lead. These changes modulate QRS morphology, including the steepness of the ECG derivative around each R wave. The slope-range method uses that morphology modulation as a surrogate respiratory signal. - -## Method summary - -For each detected R wave, the method inspects short derivative-ECG windows around the beat. It compares the strongest local upslope with the strongest local downslope and stores their difference as the EDR amplitude for that beat. Signal-aligned slope traces and the selected extrema positions make these choices available for visual inspection. Beats whose analysis windows fall outside the signal keep their beat-level alignment but do not contribute incomplete windows to the diagnostic traces. - -## Key assumptions - -The method assumes R-wave times are reliable, the derivative ECG emphasizes QRS slope information, and respiratory motion meaningfully modulates the observed ECG morphology. It is intended as an EDR amplitude series, not as a direct measurement in physical respiratory units. - -## Interpretation and limitations - -Larger EDR values indicate stronger local slope-range modulation around a beat. Interpretation should focus on trends or derived respiratory rate estimates after suitable post-processing. Plotting the diagnostic slope traces and extrema markers over the derivative ECG can reveal whether the intended QRS regions were selected. The method may be unreliable when R-wave detections are wrong, QRS morphology is unstable for non-respiratory reasons, ECG noise is high, or boundary beats lack enough neighboring samples. - -## References - -* Kontaxis et al. 2020, [doi:10.1109/TBME.2019.2923587](https://doi.org/10.1109/TBME.2019.2923587), provides the main method provenance for the slope-range EDR contract. -* Varon et al. 2020, [doi:10.1038/s41598-020-62624-5](https://doi.org/10.1038/s41598-020-62624-5), compares ECG-derived respiration approaches in ambulatory single-lead ECG. - -## Specification - -The normative contract is the generated [`ecg.sloperange` specification](../generated/specifications/ecg.sloperange.md). diff --git a/docs/scientific/hrv.fdmetrics.md b/docs/scientific/hrv.fdmetrics.md deleted file mode 100644 index b8ed553..0000000 --- a/docs/scientific/hrv.fdmetrics.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -spec_id: hrv.fdmetrics -title: Frequency-domain HRV metrics ---- - -# Frequency-domain HRV metrics - -## Purpose - -Frequency-domain HRV analysis summarizes how the variability of heart timing is distributed over frequency. The conventional form reports low-frequency (LF) and high-frequency (HF) power and two normalized ratios. The respiration-separated form instead combines spectra obtained after orthogonal subspace projection (OSP) to distinguish power linearly related to respiration from respiration-unrelated LF power. - -## Scientific rationale - -The conventional LF and HF bands provide a widely used descriptive organization of HRV spectra. They are useful for reproducible comparisons, but the physiological interpretation of each band is not unique: respiration, autonomic regulation, baroreflex dynamics, posture, activity, and analysis conditions can all influence the measured powers. In particular, respiration can move across nominal bands as breathing frequency changes. - -OSP offers a complementary description. After HRV modulation has been separated into respiration-related and residual components, their spectra can support an index based on respiration-unrelated LF power relative to the combined related and unrelated power. The resulting normalization is bounded and remains defined when related power is exactly zero, but it still reflects the quality and assumptions of the preceding decomposition. - -## Frequency-grid interpretation - -Biosiglib treats the supplied frequency grid and PSD samples as the observations. Band-edge samples are selected from that grid and integrated directly rather than estimated by interpolation. This makes the calculation transparent and preserves the mature implementation, while also making results sensitive to spectral resolution and irregular spacing near 0.04, 0.15, and 0.4 Hz. - -Partial nominal coverage is not automatically a failure. For example, a spectrum ending below 0.4 Hz can still provide an HF estimate over the represented range. Such a value should be reported with awareness that it does not cover the full conventional HF interval. - -## Diagnostics and compatibility limits - -The VLF diagnostic flags a spectrum whose integrated power below the LF boundary is large relative to the remaining represented power. It is a quality signal only and does not alter otherwise valid metrics. When more than one spectrum triggers the same condition, a single warning identifies all affected spectra so callers receive complete information without repeated messages. - -Exact zero required LF or HF power is handled separately because normalized ratios would be undefined or uninformative. This condition invalidates the selected mode's result and is reported through its own aggregated warning. A VLF warning and a zero-power warning may therefore coexist. - -The two upper-power rejections retained for the OSP-separated mode are empirical compatibility limits tied to dimensionless modulation spectra. They should not be generalized to arbitrary PSD units. For the same reason, the former large-power rejection in the conventional mode is removed: conventional spectra may legitimately use different units and scales. - -## Assumptions and limitations - -The input PSD must already be a scientifically appropriate estimate for the signal and interval under study. Biosiglib does not prescribe detrending, windowing, spectral estimation, normalization, record length, stationarity, or artifact correction in this contract. These choices can dominate the interpretation even when the final integration is numerically conformant. - -The respiration-separated metrics additionally assume that the supplied spectra correspond to the dimensionless related and unrelated modulation components from a suitable OSP analysis. They quantify association with that model, not causal respiratory influence and not a pure sympathetic or parasympathetic component. - -## References - -The conventional band terminology and normalized powers follow the Task Force recommendations (1996). Varon et al. (2019) describe HRV analysis after removing linear respiratory influences with OSP. Liu et al. (2019) present the improved time-variant cardiorespiratory relation and robust normalized index used by the separated mode. - -## Specification - -The normative contract is the generated [`hrv.fdmetrics` specification](../generated/specifications/hrv.fdmetrics.md). diff --git a/docs/scientific/hrv.fillgaps.md b/docs/scientific/hrv.fillgaps.md deleted file mode 100644 index 52d9ddf..0000000 --- a/docs/scientific/hrv.fillgaps.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -spec_id: hrv.fillgaps -title: Missing-event gap filling ---- - -# Missing-event gap filling - -## Purpose - -Missing-event gap filling reconstructs plausible event timestamps inside abnormally long intervals before interval-based HRV analysis. It operates on event times that have already undergone any desired false-positive removal. - -## Scientific rationale - -A missed beat or pulse detection merges several physiological intervals into one long observed interval. Nearby valid intervals provide local timing context from which a smooth sequence can be reconstructed, while exact duration rescaling preserves the original events on both sides of the gap. - -## Method summary - -The method detects locally long intervals with a median-filtered adaptive baseline. It tries progressively larger insertion counts across the whole series. For each gap it uses shape-preserving cubic interpolation from nearby valid intervals, accepts a reconstruction that falls below the local upper bound, and stops before an additional insertion would make all reconstructed intervals too short. - -## Key assumptions - -Input events are finite, correctly ordered, and already cleaned of false-positive detections. The surrounding valid intervals are assumed to represent the local rhythm well enough to guide interpolation. A gap needs interval support on both sides; the method does not extrapolate from only one side. - -## Interpretation and limitations - -Inserted timestamps are deterministic reconstructions, not observed physiological events. They can reduce the impact of missed detections on interval statistics, but they cannot recover true beat timing when local rhythm changes abruptly or when too much context is missing. Unresolved spans remain explicit as NaN intervals in the interval output. The threshold factors are empirical algorithm settings rather than clinical decision thresholds. - -## References - -The missing-data motivation and original empirical factors are described by Cajal et al., *Effects of Missing Data on Heart Rate Variability Metrics* (2022). The canonical defaults include later refinements recorded in the normative contract. - -## Specification - -The normative contract is the generated [`hrv.fillgaps` specification](../generated/specifications/hrv.fillgaps.md). diff --git a/docs/scientific/hrv.ipfm.md b/docs/scientific/hrv.ipfm.md deleted file mode 100644 index 5b20721..0000000 --- a/docs/scientific/hrv.ipfm.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -spec_id: hrv.ipfm -title: IPFM heart-timing reconstruction and TVIPFM modulation ---- - -# IPFM heart-timing reconstruction and TVIPFM modulation - -## Purpose - -The heart-timing approach reconstructs a continuous instantaneous-rate signal from discrete beat or pulse occurrence times. Its optional TVIPFM output estimates autonomic modulation while compensating for a slowly changing mean heart rate. - -## Scientific rationale - -Mateo and Laguna formulate heart timing as cumulative event count versus time. Differentiating a smooth interpolation of that count produces instantaneous rate in hertz. Their work specifically studies fourteenth-order spline interpolation, and later work applies the same order after removing incorrect event values. This supports the default spline order, but it does not make every edge-stabilization constant a physiological parameter. - -The virtual events added outside the observed sequence reduce boundary instability. Biosiglib fixes ten virtual events per side and estimates their spacing from at most eight nearby intervals because those values preserve the mature Biosigmat behavior. The publications do not prescribe these exact `10/8` values, so they are documented as empirical numerical constants rather than scientifically validated thresholds. - -## TVIPFM interpretation - -Under a time-varying mean heart rate, the conventional IPFM relationship causes the apparent variability amplitude to scale with that mean rate. TVIPFM Approach A estimates the slowly varying mean instantaneous rate, subtracts it from the instantaneous rate, and divides the residual by the mean. The resulting dimensionless signal represents modulation relative to the time-varying baseline rather than an unnormalized high-pass residual. - -Bailón et al. validate this correction during exercise and use a 0.03 Hz separation for the time-varying mean rate. Sörnmo, Bailón, and Laguna later derive and review the model in broader time-varying and confounded conditions. Those publications support the model semantics and cutoff, but they do not define Biosigmat's exact fourth-order Butterworth implementation or its forward-backward edge padding. - -## Assumptions and limitations - -Event times must already be finite, correctly ordered, and physiologically meaningful. High-order splines can overshoot for pathological interval patterns even without extrapolation; non-positive reconstructed rates are therefore rejected rather than clipped. Virtual events stabilize the spline near the boundaries but do not justify evaluating outside the observed time interval. - -The modulating signal assumes a positive, slowly varying mean rate and enough uniformly sampled data to support zero-phase filtering. It should be interpreted as a model-based autonomic modulation estimate, not as a direct physiological measurement or a generic detrended HRV series. - -## References - -The heart-timing reconstruction and order-14 evidence come from Mateo and Laguna (2000, 2003). The TVIPFM correction and exercise validation come from Bailón et al. (2011), with later derivation and review context from Sörnmo, Bailón, and Laguna (2024). - -## Specification - -The normative contract is the generated [`hrv.ipfm` specification](../generated/specifications/hrv.ipfm.md). diff --git a/docs/scientific/hrv.osp.md b/docs/scientific/hrv.osp.md deleted file mode 100644 index 7cf733d..0000000 --- a/docs/scientific/hrv.osp.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -spec_id: hrv.osp -title: Respiratory decomposition by orthogonal subspace projection ---- - -# Respiratory decomposition by orthogonal subspace projection - -## Purpose - -Respiration changes heart rate through respiratory sinus arrhythmia and other cardiorespiratory interactions. Those changes can move across conventional HRV frequency bands when breathing rate varies, which complicates interpreting spectral powers as fixed autonomic components. Orthogonal subspace projection (OSP) separates the uniformly sampled HRV modulation into a part that can be represented by respiration and delayed copies of respiration, and a residual part outside that linear subspace. - -## Scientific rationale - -Varon and colleagues use OSP to quantify and remove linear respiratory influences from HRV. The respiration-related component is obtained by projecting HRV onto a basis constructed from respiration, while the residual contains the dynamics not represented by that basis. Their studies support this decomposition and show why accounting for respiration can improve HRV interpretation under changing respiratory patterns. - -The residual is not a respiration-free physiological signal in an absolute sense. OSP removes only the component that is linearly represented by the chosen respiratory subspace. Nonlinear respiratory effects, measurement noise, unrelated autonomic modulation, and errors in the respiratory signal can remain. - -## Adaptive respiratory subspace - -Biosiglib preserves the Biosigmat choice of an adaptive model order spanning approximately two cycles of the selected respiratory frequency. This connects faster breathing to a shorter delayed basis and slower breathing to a longer one. The resulting order also determines the samples lost while the delayed vectors become fully defined, so the decomposition starts later than the original synchronized signals. - -The preceding dominant-frequency selection is deliberately documented as an inherited empirical heuristic. Its 90% occupied-power band, peak-count-dependent choice, and minimum-frequency floor are compatibility decisions, not mathematical requirements of OSP and not optimal values established by the cited publications. Implementations should preserve them for reproducibility without assigning them broader physiological authority. - -## Numerical reproducibility - -The delayed respiration columns can be dependent or nearly dependent. Such subspaces remain meaningful, but their Gram matrix requires a truncated pseudoinverse. Biosiglib fixes the binary64 threshold used by the mature MATLAB calculation so MATLAB and Python retain the same singular directions instead of relying on different library defaults. - -This internal rank decision is separate from the tolerances used to compare returned components. Forming the Gram matrix squares the subspace condition number, so the residual orthogonality check is intentionally less strict than direct component and reconstruction comparisons. - -## Assumptions and limitations - -The HRV modulation and respiration must already share a uniform sampling grid and time alignment. Biosiglib does not define how respiration is measured, preprocessed, or converted into a PSD within this contract. Poor synchronization, artifacts, weak respiratory information, or a PSD that does not represent the analyzed segment can make the mathematical decomposition physiologically misleading even when it is numerically conformant. - -The respiration-related output describes association with the selected linear subspace, not causal influence. Likewise, the residual should not be interpreted as a pure sympathetic component or as proof that respiratory effects have been completely removed. - -## References - -The delayed-respiration OSP interpretation and its use during emotional stress are described by Varon et al. (2017). The broader HRV analysis and validation after removing respiratory influences are presented by Varon et al. (2019). - -## Specification - -The normative contract is the generated [`hrv.osp` specification](../generated/specifications/hrv.osp.md). diff --git a/docs/scientific/hrv.removefp.md b/docs/scientific/hrv.removefp.md deleted file mode 100644 index 2021bcd..0000000 --- a/docs/scientific/hrv.removefp.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -spec_id: hrv.removefp -title: False-positive event removal ---- - -# False-positive event removal - -## Purpose - -False-positive event removal cleans a beat- or pulse-detection time series before interval analysis or missing-event reconstruction. It is intended to remove detections that occur implausibly soon after a preceding event relative to the local timing pattern. - -## Scientific rationale - -An extra detection creates an abnormally short event-to-event interval. Comparing each interval with a median-filtered local baseline provides a robust deterministic way to identify these short intervals without using the event-signal morphology. - -## Method summary - -The method derives all successive intervals from the original event series, obtains an adaptive local baseline, and flags sufficiently short intervals. It then removes the later event of each flagged pair simultaneously. The baseline is not recomputed after removal, so the operation remains a transparent single preprocessing pass. - -## Key assumptions - -The event series must already be finite and correctly ordered. The method assumes an abnormally short interval is evidence of an extra detection and that removing the later event is the intended deterministic choice. The fixed threshold settings are empirical algorithm constants inherited from prior Biosigmat use. - -## Interpretation and limitations - -The result should be inspected as a cleaned event sequence, not interpreted as a clinical classification. Timestamps alone may not distinguish an extra detection from a nearby true event, so the rule can retain a false detection and remove a true event in ambiguous local patterns. Signal morphology or detector-quality information may support more advanced methods, but those inputs are outside this contract. - -## References - -No primary clinical-validation reference is claimed for the empirical threshold constants in this initial contract. - -## Specification - -The normative contract is the generated [`hrv.removefp` specification](../generated/specifications/hrv.removefp.md). diff --git a/docs/scientific/index.md b/docs/scientific/index.md deleted file mode 100644 index 413cfa8..0000000 --- a/docs/scientific/index.md +++ /dev/null @@ -1,49 +0,0 @@ -# Scientific Notes - -Scientific notes give researchers a short, human-friendly explanation of an algorithm's purpose, rationale, assumptions, interpretation, and limitations. - -They are explanatory documentation. They do not replace, override, or extend the normative JSON specification or its conformance cases. - -## Authority - -The Biosiglib contract is interpreted in this order: - -1. JSON specifications and conformance cases are normative. -2. Scientific notes explain the method and must not contradict the corresponding specification. -3. Implementations conform to the specification and conformance cases. - -When a scientific note and a JSON specification disagree, the JSON specification and conformance cases remain the source of truth. The disagreement should be fixed by updating the incorrect explanatory text or, when the specification itself is wrong, by changing the specification and reviewing the downstream impact. - -## Writing Rules - -Each scientific note must: - -* declare the corresponding `spec_id` in Markdown front matter; -* link to the generated normative specification page; -* explain the scientific or signal-processing idea in accessible language; -* keep assumptions and limitations focused on scientific interpretation; -* avoid duplicating full input, parameter, output, tolerance, or edge-case definitions from the JSON specification. - -Scientific notes may summarize the algorithm at a high level, but detailed contracts belong in `specs/*/*/spec.json` and `conformance/*/*/*.json`. - -## Review Expectations - -Changes touching specifications or scientific notes require a consistency review. Reviewers should check that: - -* the note's `spec_id` points to an existing specification; -* the note is listed from this documentation section or the MkDocs navigation; -* the note does not introduce normative behavior absent from the JSON specification; -* specification changes are reflected in related notes when the scientific explanation changes; -* note changes do not contradict inputs, outputs, units, assumptions, behavior, references, or conformance cases. - -## Current Notes - -* [`ecg.baselineremove`](ecg.baselineremove.md) - ECG baseline removal from fiducial isoelectric samples. -* [`ecg.sloperange`](ecg.sloperange.md) - slope-range ECG-derived respiration. -* [`hrv.fillgaps`](hrv.fillgaps.md) - iterative reconstruction of missing event timestamps. -* [`hrv.ipfm`](hrv.ipfm.md) - heart-timing instantaneous-rate reconstruction and TVIPFM modulation. -* [`hrv.fdmetrics`](hrv.fdmetrics.md) - conventional and respiration-separated frequency-domain HRV metrics. -* [`hrv.osp`](hrv.osp.md) - respiratory and residual HRV decomposition by orthogonal subspace projection. -* [`hrv.removefp`](hrv.removefp.md) - deterministic removal of event detections that follow abnormally short intervals. - -Use the [scientific-note template](template.md) when adding one. diff --git a/docs/scientific/template.md b/docs/scientific/template.md deleted file mode 100644 index d946b60..0000000 --- a/docs/scientific/template.md +++ /dev/null @@ -1,42 +0,0 @@ -# Scientific-Note Template - -Use this concise structure for new scientific notes. Replace the placeholder `spec_id`, title, and section text, then add the note to `docs/scientific/index.md` or `mkdocs.yml`. - -Do not duplicate the full normative input, parameter, output, tolerance, or edge-case definitions from the JSON specification. - -```markdown ---- -spec_id: ecg.example -title: Human-readable method title ---- - -# Human-readable method title - -## Purpose - -Briefly state what the method estimates or detects and when it is useful. - -## Scientific rationale - -Explain the physiological or signal-processing idea behind the method. - -## Method summary - -Summarize the algorithm in readable language. Do not duplicate the full normative input/output contract. - -## Key assumptions - -List only assumptions that matter scientifically or methodologically. - -## Interpretation and limitations - -Explain how to interpret the result and when the method may be unreliable. - -## References - -List or link the main methodological references. - -## Specification - -Link to the corresponding normative specification. -``` diff --git a/docs/specifications.md b/docs/specifications.md deleted file mode 100644 index 9958079..0000000 --- a/docs/specifications.md +++ /dev/null @@ -1,49 +0,0 @@ -# Specifications - -Biosiglib specifications are machine-readable JSON files validated against the repository schemas. They define the behavior that implementations must preserve across languages. - -A specification can describe: - -* canonical inputs and outputs; -* units, shapes, and data types; -* parameters and default values; -* mathematical and computational definitions; -* missing-value and edge-case behavior; -* numerical comparison requirements; -* scientific provenance; -* associated fixtures and conformance cases. - -Specification fields are separated into normative behavior and informative documentation. Normative fields affect conformance. Informative fields help explain the algorithm without creating a separate source of truth. - -## Current Specifications - -The current specifications are: - - -| Specification | Module | Summary | -| --- | --- | --- | -| [`ecg.baselineremove`](generated/specifications/ecg.baselineremove.md) | ECG | Estimates a slowly varying ECG baseline from local means around fiducial positions and subtracts its spline interpolation. | -| [`ecg.pantompkins`](generated/specifications/ecg.pantompkins.md) | ECG | Detects ordered R-wave occurrence times from a sampled ECG signal and exposes intermediate processing signals for plotting and debugging. | -| [`ecg.sloperange`](generated/specifications/ecg.sloperange.md) | ECG | Estimates an ECG-derived respiration amplitude series from derivative ECG morphology around detected R waves. | -| [`hrv.fdmetrics`](generated/specifications/hrv.fdmetrics.md) | HRV | Integrates conventional LF and HF powers or respiration-separated OSP powers on an authoritative frequency grid. | -| [`hrv.fillgaps`](generated/specifications/hrv.fillgaps.md) | HRV | Reconstructs missing event timestamps by iteratively interpolating intervals inside locally detected gaps. | -| [`hrv.ipfm`](generated/specifications/hrv.ipfm.md) | HRV | Estimates uniformly sampled instantaneous heart rate and an optional TVIPFM autonomic modulating signal from event times. | -| [`hrv.osp`](generated/specifications/hrv.osp.md) | HRV | Separates a uniformly sampled HRV modulating signal into a component linearly related to respiration and an orthogonal residual. | -| [`hrv.removefp`](generated/specifications/hrv.removefp.md) | HRV | Removes detections that follow abnormally short event-to-event intervals using a fixed adaptive-baseline rule. | -| [`hrv.tdmetrics`](generated/specifications/hrv.tdmetrics.md) | HRV | Computes standard time-domain HRV metrics from cleaned beat-to-beat or pulse-to-pulse intervals. | -| [`tools.lpd_filter`](generated/specifications/tools.lpd_filter.md) | Tools | Designs a low-pass differentiating FIR filter and reports its linear-phase delay. | -| [`tools.medfilt_threshold`](generated/specifications/tools.medfilt_threshold.md) | Tools | Computes a capped adaptive threshold from a one-dimensional signal using median-filter-based local baseline estimation. | -| [`tools.nan_filter`](generated/specifications/tools.nan_filter.md) | Tools | Applies ordinary causal filtering while interpolating short NaN gaps and preserving long NaN gaps. | -| [`tools.nan_filtfilt`](generated/specifications/tools.nan_filtfilt.md) | Tools | Applies ordinary zero-phase filtering while interpolating short NaN gaps and preserving long NaN gaps. | -| [`tools.snap_to_peak`](generated/specifications/tools.snap_to_peak.md) | Tools | Refines detection sample positions by moving each detection to the maximum signal sample in a NaN-aware local search window. | - - -These specifications are not the final Biosiglib scope. The generated catalog expands as new contracts and their complete cross-language conformance work become ready together. - -## Generated Pages - -The algorithm-specific pages are generated from the JSON specifications and committed under `docs/generated/specifications/`. The specification table above and its MkDocs navigation entries are generated at the same time. Do not edit the delimited generated blocks manually; update the JSON source and run `python tools/generate_docs.py` instead. - -## JSON Remains Normative - -The human-readable pages summarize the JSON specifications. They are generated views of the JSON files, not separate normative copies. diff --git a/mkdocs.yml b/mkdocs.yml index 2fdfb69..69b083b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,41 +28,32 @@ theme: plugins: - search +hooks: + - tools/generate_docs.py + nav: - Home: index.md - - Ecosystem: ecosystem.md - - Specifications: - - Overview: specifications.md - # BEGIN GENERATED SPECIFICATION NAVIGATION - - ecg.baselineremove: generated/specifications/ecg.baselineremove.md - - ecg.pantompkins: generated/specifications/ecg.pantompkins.md - - ecg.sloperange: generated/specifications/ecg.sloperange.md - - hrv.fdmetrics: generated/specifications/hrv.fdmetrics.md - - hrv.fillgaps: generated/specifications/hrv.fillgaps.md - - hrv.ipfm: generated/specifications/hrv.ipfm.md - - hrv.osp: generated/specifications/hrv.osp.md - - hrv.removefp: generated/specifications/hrv.removefp.md - - hrv.tdmetrics: generated/specifications/hrv.tdmetrics.md - - tools.lpd_filter: generated/specifications/tools.lpd_filter.md - - tools.medfilt_threshold: generated/specifications/tools.medfilt_threshold.md - - tools.nan_filter: generated/specifications/tools.nan_filter.md - - tools.nan_filtfilt: generated/specifications/tools.nan_filtfilt.md - - tools.snap_to_peak: generated/specifications/tools.snap_to_peak.md - # END GENERATED SPECIFICATION NAVIGATION - - Scientific Notes: - - Overview: scientific/index.md - - ecg.baselineremove: scientific/ecg.baselineremove.md - - ecg.sloperange: scientific/ecg.sloperange.md - - hrv.fillgaps: scientific/hrv.fillgaps.md - - hrv.ipfm: scientific/hrv.ipfm.md - - hrv.fdmetrics: scientific/hrv.fdmetrics.md - - hrv.osp: scientific/hrv.osp.md - - hrv.removefp: scientific/hrv.removefp.md - - Template: scientific/template.md - - Conformance: conformance.md - - Releases: releases.md - - Citation: citation.md - - Development: development.md + - Methods: + - Overview: methods/index.md + - ECG: + - Baseline removal: methods/ecg.baselineremove.md + - Pan-Tompkins detector: methods/ecg.pantompkins.md + - Slope-range respiration: methods/ecg.sloperange.md + - HRV: + - Frequency-domain metrics: methods/hrv.fdmetrics.md + - Gap filling: methods/hrv.fillgaps.md + - IPFM reconstruction: methods/hrv.ipfm.md + - Orthogonal subspace projection: methods/hrv.osp.md + - False-positive removal: methods/hrv.removefp.md + - Time-domain metrics: methods/hrv.tdmetrics.md + - Utilities: + - LPD filter: methods/tools.lpd_filter.md + - Median-filter threshold: methods/tools.medfilt_threshold.md + - NaN-aware filter: methods/tools.nan_filter.md + - NaN-aware zero-phase filter: methods/tools.nan_filtfilt.md + - Snap to peak: methods/tools.snap_to_peak.md + - Implementations: implementations.md + - Cite: citation.md markdown_extensions: - admonition diff --git a/tests/test_generate_docs.py b/tests/test_generate_docs.py index 19d0c75..538c27a 100644 --- a/tests/test_generate_docs.py +++ b/tests/test_generate_docs.py @@ -1,10 +1,11 @@ -"""Tests for generated specification documentation and navigation.""" +"""Tests for build-time method documentation rendering.""" from __future__ import annotations import sys import unittest from pathlib import Path +from types import SimpleNamespace REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -13,61 +14,43 @@ import generate_docs # noqa: E402 -class GeneratedSpecificationIndexTests(unittest.TestCase): - def test_renders_index_and_navigation_from_specification_metadata(self) -> None: - specs = [ - ( - Path("specs/ecg/example/spec.json"), - { - "metadata": {"id": "ecg.example", "module": "ecg"}, - "informative": {"summary": "Example ECG algorithm."}, - }, - ), - ( - Path("specs/tools/helper/spec.json"), - { - "metadata": {"id": "tools.helper", "module": "tools"}, - "informative": {"summary": "Example helper."}, - }, - ), - ] - - index = "\n".join(generate_docs.specification_index_lines(specs)) - navigation = "\n".join(generate_docs.specification_navigation_lines(specs)) - - self.assertIn( - "| [`ecg.example`](generated/specifications/ecg.example.md) | ECG | " - "Example ECG algorithm. |", - index, - ) - self.assertIn( - "| [`tools.helper`](generated/specifications/tools.helper.md) | Tools | " - "Example helper. |", - index, - ) - self.assertEqual( - navigation, - " - ecg.example: generated/specifications/ecg.example.md\n" - " - tools.helper: generated/specifications/tools.helper.md", - ) - - def test_replaces_only_the_delimited_generated_block(self) -> None: - updated = generate_docs.replace_generated_block( - "before\nSTART\nold\nEND\nafter\n", - "START", - "END", - ["new", "content"], - "example.md", - ) - - self.assertEqual(updated, "before\nSTART\nnew\ncontent\nEND\nafter\n") - - def test_repository_generated_documentation_is_current(self) -> None: - generated = generate_docs.generated_documentation(REPOSITORY_ROOT) - - for path, expected in generated.items(): - with self.subTest(path=path): - self.assertEqual(path.read_text(encoding="utf-8"), expected) +class MethodDocumentationRenderingTests(unittest.TestCase): + def test_renders_practical_interface_from_contract_and_descriptions(self) -> None: + rendered = generate_docs.render_method_interface("hrv.tdmetrics") + + self.assertIn("**Canonical ID:** `hrv.tdmetrics`", rendered) + self.assertIn("| `dtk` |", rendered) + self.assertIn("Clean beat-to-beat or pulse-to-pulse intervals", rendered) + self.assertIn("| `mhr` |", rendered) + + def test_renders_references_and_technical_links_without_case_tables(self) -> None: + rendered = generate_docs.render_method_resources("ecg.pantompkins") + + self.assertIn("## References", rendered) + self.assertIn("Python source", rendered) + self.assertIn("MATLAB source", rendered) + self.assertIn("Normative JSON", rendered) + self.assertIn("Validation cases", rendered) + self.assertNotIn("Expected outputs", rendered) + + def test_renders_complete_method_catalog(self) -> None: + rendered = generate_docs.render_method_catalog() + specification_ids = generate_docs.repository_data()[0] + + for specification_id in specification_ids: + with self.subTest(specification_id=specification_id): + self.assertIn(f"({specification_id}.md)", rendered) + + def test_hook_replaces_markers_without_changing_source_file(self) -> None: + source_path = REPOSITORY_ROOT / "docs" / "methods" / "hrv.tdmetrics.md" + source = source_path.read_text(encoding="utf-8") + page = SimpleNamespace(file=SimpleNamespace(src_uri="methods/hrv.tdmetrics.md")) + + rendered = generate_docs.on_page_markdown(source, page) + + self.assertNotIn(generate_docs.METHOD_INTERFACE_MARKER, rendered) + self.assertNotIn(generate_docs.METHOD_RESOURCES_MARKER, rendered) + self.assertEqual(source_path.read_text(encoding="utf-8"), source) if __name__ == "__main__": diff --git a/tests/test_validate_specs.py b/tests/test_validate_specs.py index 5fbbfca..66d96f8 100644 --- a/tests/test_validate_specs.py +++ b/tests/test_validate_specs.py @@ -117,61 +117,74 @@ def test_rejects_expected_warning_on_error_case(self) -> None: self.assertNotEqual(list(validator.iter_errors(case)), []) -class SpecificationDocumentationValidationTests(unittest.TestCase): +class MethodDocumentationValidationTests(unittest.TestCase): def setUp(self) -> None: self.temporary_directory = tempfile.TemporaryDirectory() self.addCleanup(self.temporary_directory.cleanup) self.root = Path(self.temporary_directory.name) self.specification_id = "tools.example" - self.documentation_reference = ( - f"generated/specifications/{self.specification_id}.md" + self.documentation_reference = f"methods/{self.specification_id}.md" + self.method_dir = self.root / "docs" / "methods" + self.documentation_path = self.method_dir / f"{self.specification_id}.md" + self.method_dir.mkdir(parents=True) + self.documentation_path.write_text( + "---\nspec_id: tools.example\n---\n\n# Example\n\n" + "\n\n" + "\n", + encoding="utf-8", ) - self.documentation_path = ( - self.root / "docs" / "generated" / "specifications" - / f"{self.specification_id}.md" + (self.method_dir / "index.md").write_text( + "# Methods\n\n\n", encoding="utf-8" ) - self.documentation_path.parent.mkdir(parents=True) - self.documentation_path.write_text("# Example\n", encoding="utf-8") - (self.root / "docs" / "specifications.md").write_text( - f"[Example]({self.documentation_reference})\n", + (self.method_dir / "descriptions.json").write_text( + '{"tools.example":{"signal":"Input signal.","result":"Output signal."}}\n', encoding="utf-8", ) (self.root / "mkdocs.yml").write_text( + "hooks:\n - tools/generate_docs.py\n" f"nav:\n - Example: {self.documentation_reference}\n", encoding="utf-8", ) - self.specs_by_id = {self.specification_id: {}} + self.specs_by_id = { + self.specification_id: { + "input_ids": {"signal"}, + "parameter_ids": set(), + "output_ids": {"result"}, + } + } def validate(self) -> list[str]: - return validate_specs.validate_specification_documentation( + return validate_specs.validate_method_documentation( self.root, self.specs_by_id, ) - def test_accepts_generated_indexed_and_navigable_specification(self) -> None: + def test_accepts_complete_navigable_method_page(self) -> None: self.assertEqual(self.validate(), []) - def test_rejects_missing_generated_specification_page(self) -> None: + def test_rejects_missing_method_page(self) -> None: self.documentation_path.unlink() errors = self.validate() self.assertTrue( - any("generated page" in error and "is missing" in error for error in errors) + any("method page" in error and "is missing" in error for error in errors) ) - def test_rejects_specification_missing_from_index(self) -> None: - (self.root / "docs" / "specifications.md").write_text( - "# Specifications\n", + def test_rejects_missing_render_marker(self) -> None: + self.documentation_path.write_text( + "---\nspec_id: tools.example\n---\n\n# Example\n", encoding="utf-8", ) errors = self.validate() - self.assertTrue(any("is not listed" in error for error in errors)) + self.assertTrue(any("method interface marker" in error for error in errors)) - def test_rejects_specification_missing_from_navigation(self) -> None: - (self.root / "mkdocs.yml").write_text("nav: []\n", encoding="utf-8") + def test_rejects_method_missing_from_navigation(self) -> None: + (self.root / "mkdocs.yml").write_text( + "hooks:\n - tools/generate_docs.py\nnav: []\n", encoding="utf-8" + ) errors = self.validate() @@ -179,6 +192,17 @@ def test_rejects_specification_missing_from_navigation(self) -> None: any("is not listed in navigation" in error for error in errors) ) + def test_rejects_incomplete_field_descriptions(self) -> None: + (self.method_dir / "descriptions.json").write_text( + '{"tools.example":{"signal":"Input signal."}}\n', encoding="utf-8" + ) + + errors = self.validate() + + self.assertTrue( + any("missing a description for 'result'" in error for error in errors) + ) + class ExistingNegativeValidationTests(unittest.TestCase): def test_rejects_unknown_warning_and_affected_id(self) -> None: diff --git a/tools/generate_docs.py b/tools/generate_docs.py index 9c87748..e55e910 100644 --- a/tools/generate_docs.py +++ b/tools/generate_docs.py @@ -1,35 +1,26 @@ -"""Generate MkDocs specification documentation from Biosiglib JSON specifications.""" +"""Render user-facing method data during the MkDocs build.""" from __future__ import annotations -import argparse import json -import sys +from functools import lru_cache from pathlib import Path from typing import Any -GENERATED_SPEC_DIR = Path("docs") / "generated" / "specifications" -SPECIFICATION_INDEX_PATH = Path("docs") / "specifications.md" -MKDOCS_PATH = Path("mkdocs.yml") -SPEC_GLOB = "specs/*/*/spec.json" +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] REPOSITORY_URL = "https://github.com/BSICoS/biosiglib" -SPECIFICATION_TABLE_START = "" -SPECIFICATION_TABLE_END = "" -SPECIFICATION_NAV_START = " # BEGIN GENERATED SPECIFICATION NAVIGATION" -SPECIFICATION_NAV_END = " # END GENERATED SPECIFICATION NAVIGATION" - - -def find_repository_root() -> Path: - """Find the repository root from this script location.""" - for candidate in Path(__file__).resolve().parents: - if ( - (candidate / "AGENTS.md").is_file() - and (candidate / "schemas").is_dir() - and (candidate / "specs").is_dir() - ): - return candidate - raise RuntimeError("Could not locate the Biosiglib repository root.") +METHOD_INTERFACE_MARKER = "" +METHOD_RESOURCES_MARKER = "" +METHOD_CATALOG_MARKER = "" +DESCRIPTION_PATH = REPOSITORY_ROOT / "docs" / "methods" / "descriptions.json" +MATLAB_NAMES = { + "tools.lpd_filter": "lpdfilter", + "tools.medfilt_threshold": "medfiltThreshold", + "tools.nan_filter": "nanfilter", + "tools.nan_filtfilt": "nanfiltfilt", + "tools.snap_to_peak": "snaptopeak", +} def load_json(path: Path) -> Any: @@ -37,480 +28,266 @@ def load_json(path: Path) -> Any: return json.load(handle) -def relative_path(path: Path, root: Path) -> str: - return path.relative_to(root).as_posix() - - -def markdown_escape_cell(value: object) -> str: - text = "" if value is None else str(value) - return text.replace("|", "\\|").replace("\n", "
") - - -def inline_code(value: object) -> str: - return f"`{value}`" - - -def json_scalar(value: Any) -> str: - if value is None: - return "" - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, str): - return value - return json.dumps(value, sort_keys=True) - - -def format_default(value: Any) -> str: - if value is None: - return "" - return json.dumps(value, sort_keys=True) +@lru_cache(maxsize=1) +def repository_data() -> tuple[ + dict[str, tuple[Path, dict[str, Any]]], + dict[str, Any], + dict[str, Any], +]: + specs: dict[str, tuple[Path, dict[str, Any]]] = {} + for path in sorted((REPOSITORY_ROOT / "specs").rglob("spec.json")): + spec = load_json(path) + specification_id = spec["metadata"]["id"] + specs[specification_id] = (path, spec) + references = { + reference["id"]: reference + for reference in load_json(REPOSITORY_ROOT / "references" / "references.json")["references"] + } + descriptions = load_json(DESCRIPTION_PATH) + return specs, references, descriptions -def format_constraints(value: Any) -> str: - if not isinstance(value, dict) or not value: - return "None" - parts = [] - for key in sorted(value): - parts.append(f"{key}={json.dumps(value[key], sort_keys=True)}") - return ", ".join(parts) +def markdown_escape(value: object) -> str: + return str(value).replace("|", "\\|").replace("\n", "
") -def table(headers: list[str], rows: list[list[object]]) -> list[str]: +def table(headers: list[str], rows: list[list[object]]) -> str: lines = [ "| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |", ] - for row in rows: - lines.append("| " + " | ".join(markdown_escape_cell(cell) for cell in row) + " |") - return lines - - -def title_for_key(key: str) -> str: - return key.replace("_", " ").capitalize() - - -def ordered_behavior_keys(behavior: dict[str, Any]) -> list[str]: - preferred = [ - "nan_handling", - "empty_input", - "input_orientation", - "insufficient_data", - ] - keys = [key for key in preferred if key in behavior] - keys.extend(sorted(key for key in behavior if key not in set(preferred))) - return keys - - -def discover_specs(root: Path) -> list[tuple[Path, dict[str, Any]]]: - specs = [] - for spec_path in sorted(root.glob(SPEC_GLOB)): - spec = load_json(spec_path) - if not isinstance(spec, dict): - raise ValueError(f"{relative_path(spec_path, root)} did not contain a JSON object") - specs.append((spec_path, spec)) - - return sorted( - specs, - key=lambda item: ( - item[1].get("metadata", {}).get("id", ""), - relative_path(item[0], root), - ), + lines.extend( + "| " + " | ".join(markdown_escape(cell) for cell in row) + " |" + for row in rows ) + return "\n".join(lines) -def module_display_name(module: str) -> str: - if module in {"ecg", "hrv", "ppg"}: - return module.upper() - return module.replace("_", " ").title() +def format_value(value: Any) -> str: + if isinstance(value, bool): + return "yes" if value else "no" + if value is None: + return "not applicable" + if isinstance(value, (list, dict)): + return f"`{json.dumps(value, sort_keys=True)}`" + return f"`{value}`" -def specification_index_lines(specs: list[tuple[Path, dict[str, Any]]]) -> list[str]: +def format_requirements(entry: dict[str, Any]) -> str: + parts = [] + constraints = entry.get("constraints", {}) + labels = { + "minimum": "minimum", + "exclusive_minimum": "greater than", + "maximum": "maximum", + "exclusive_maximum": "less than", + "minimum_length": "minimum length", + } + for key, value in constraints.items(): + parts.append(f"{labels.get(key, key.replace('_', ' '))}: {value}") + if entry.get("allow_nan") is True: + parts.append("NaN allowed") + elif entry.get("allow_nan") is False: + parts.append("no NaN") + if entry.get("allow_inf") is True: + parts.append("Inf allowed") + elif entry.get("allow_inf") is False: + parts.append("finite") + return "; ".join(parts) or "not specified" + + +def field_rows( + specification_id: str, + entries: list[dict[str, Any]], + *, + include_default: bool = False, + include_requirements: bool = True, +) -> list[list[object]]: + _, _, descriptions = repository_data() + field_descriptions = descriptions[specification_id] rows = [] - for spec_path, spec in specs: - metadata = spec.get("metadata", {}) - informative = spec.get("informative", {}) - if not isinstance(metadata, dict) or not isinstance(metadata.get("id"), str): - raise ValueError(f"{spec_path.as_posix()} is missing metadata.id") - if not isinstance(informative, dict): - informative = {} - - specification_id = metadata["id"] - module = str(metadata.get("module", "")) - rows.append( - [ - f"[{inline_code(specification_id)}](generated/specifications/{specification_id}.md)", - module_display_name(module), - informative.get("summary", ""), - ] - ) - - return table(["Specification", "Module", "Summary"], rows) - - -def specification_navigation_lines( - specs: list[tuple[Path, dict[str, Any]]], -) -> list[str]: - lines = [] - for spec_path, spec in specs: - metadata = spec.get("metadata", {}) - if not isinstance(metadata, dict) or not isinstance(metadata.get("id"), str): - raise ValueError(f"{spec_path.as_posix()} is missing metadata.id") - specification_id = metadata["id"] - lines.append( - f" - {specification_id}: generated/specifications/{specification_id}.md" - ) - return lines - - -def replace_generated_block( - source: str, - start_marker: str, - end_marker: str, - generated_lines: list[str], - source_name: str, -) -> str: - if source.count(start_marker) != 1 or source.count(end_marker) != 1: - raise ValueError( - f"{source_name} must contain exactly one '{start_marker}' and one '{end_marker}'" - ) - - prefix, remainder = source.split(start_marker, maxsplit=1) - _, suffix = remainder.split(end_marker, maxsplit=1) - generated = "\n".join(generated_lines) - return f"{prefix}{start_marker}\n{generated}\n{end_marker}{suffix}" - - -def conformance_cases(root: Path, module: str, algorithm: str) -> list[tuple[Path, str]]: - case_dir = root / "conformance" / module / algorithm - cases = [] - for case_path in sorted(case_dir.glob("*.json")): - case = load_json(case_path) - case_id = case.get("id") if isinstance(case, dict) else case_path.stem - cases.append((case_path, str(case_id))) - return cases - - -def github_blob_url(root: Path, path: Path) -> str: - return f"{REPOSITORY_URL}/blob/main/{relative_path(path, root)}" - - -def render_spec_page(root: Path, spec_path: Path, spec: dict[str, Any]) -> str: - metadata = spec.get("metadata", {}) - informative = spec.get("informative", {}) - provenance = spec.get("provenance", {}) - normative = spec.get("normative", {}) - - if not isinstance(metadata, dict): - metadata = {} - if not isinstance(informative, dict): - informative = {} - if not isinstance(provenance, dict): - provenance = {} - if not isinstance(normative, dict): - normative = {} + for entry in entries: + row: list[object] = [ + f"`{entry['id']}`", + field_descriptions[entry["id"]], + entry.get("data_type", "").replace("_", " "), + entry.get("unit", "dimensionless"), + ] + if include_default: + row.append(format_value(entry.get("default"))) + if include_requirements: + row.append(format_requirements(entry)) + rows.append(row) + return rows + + +def format_authors(reference: dict[str, Any]) -> str: + names = [] + for author in reference.get("authors", []): + if author.get("type") == "organization": + names.append(author["name"]) + else: + names.append( + " ".join( + part + for part in (author.get("given_names", ""), author.get("family_name", "")) + if part + ) + ) + if len(names) > 3: + return f"{names[0]} et al." + if len(names) == 2: + return " and ".join(names) + return ", ".join(names) + + +def format_reference(reference: dict[str, Any]) -> str: + authors = format_authors(reference) + year = reference.get("year", "") + title = reference.get("title", "") + journal = reference.get("journal") + citation = f"{authors} ({year}). *{title}*." + if journal: + citation += f" {journal}." + if doi := reference.get("doi"): + citation += f" [doi:{doi}](https://doi.org/{doi})" + elif pmid := reference.get("pmid"): + citation += f" [PMID:{pmid}](https://pubmed.ncbi.nlm.nih.gov/{pmid}/)" + return citation + + +def code_links(specification_id: str, module: str, algorithm: str) -> str: + matlab_name = MATLAB_NAMES.get(specification_id, algorithm) + python_path = f"src/biosigpy/{module}/{algorithm}.py" + matlab_path = f"src/{module}/{matlab_name}.m" + return ( + f"[Python source](https://github.com/BSICoS/biosigpy/blob/main/{python_path}) | " + f"[MATLAB source](https://github.com/BSICoS/biosigmat/blob/main/{matlab_path})" + ) - specification_id = str(metadata.get("id", spec_path.parent.name)) - module = str(metadata.get("module", "")) - algorithm = spec_path.parent.name - title = str(informative.get("title", specification_id)) - lines = [ - f"# {title}", - "", - "!!! warning \"Generated page\"", - " This page is generated from the Biosiglib JSON specification. Do not edit it manually; update the JSON source and run `python tools/generate_docs.py` instead.", +def render_method_interface(specification_id: str) -> str: + specs, _, _ = repository_data() + _, spec = specs[specification_id] + normative = spec["normative"] + sections = [ + f"**Canonical ID:** `{specification_id}`", "", - "## Metadata", + "## Inputs", "", + table( + ["Name", "Meaning", "Type", "Unit", "Requirements"], + field_rows(specification_id, normative["inputs"]), + ), ] - lines.extend( - table( - ["Field", "Value"], + parameters = normative.get("parameters", []) + if parameters: + sections.extend( [ - ["Canonical specification ID", inline_code(specification_id)], - ["Module", inline_code(module)], - [ - "Source JSON", - f"[{relative_path(spec_path, root)}]({github_blob_url(root, spec_path)})", - ], - ], + "", + "## Parameters", + "", + table( + ["Name", "Meaning", "Type", "Unit", "Default", "Requirements"], + field_rows(specification_id, parameters, include_default=True), + ), + ] ) + + sections.extend( + [ + "", + "## Outputs", + "", + table( + ["Name", "Meaning", "Type", "Unit"], + field_rows( + specification_id, + normative["outputs"], + include_requirements=False, + ), + ), + ] ) - summary = informative.get("summary") - description = informative.get("description") - if summary or description: - lines.extend(["", "## Summary", ""]) - if summary: - lines.extend([str(summary), ""]) - if description: - lines.append(str(description)) - - keywords = informative.get("keywords", []) - if keywords: - lines.extend(["", "## Keywords", ""]) - lines.append(", ".join(inline_code(keyword) for keyword in keywords)) - - references = provenance.get("references", []) if isinstance(provenance, dict) else [] - lines.extend(["", "## Scientific References", ""]) - if references: - rows = [] - for reference in references: - if not isinstance(reference, dict): - continue - rows.append( - [ - inline_code(reference.get("id", "")), - reference.get("relation", ""), - reference.get("note", ""), - ] - ) - lines.extend(table(["ID", "Relation", "Note"], rows)) - else: - lines.append("No scientific references are listed in this specification.") + return "\n".join(sections) - inputs = normative.get("inputs", []) if isinstance(normative, dict) else [] - lines.extend(["", "## Inputs", ""]) - lines.extend( - table( - ["id", "data_type", "shape", "unit", "allow_nan", "allow_inf", "constraints"], - [ - [ - inline_code(entry.get("id", "")), - entry.get("data_type", ""), - entry.get("shape", ""), - entry.get("unit", ""), - json_scalar(entry.get("allow_nan")), - json_scalar(entry.get("allow_inf")), - format_constraints(entry.get("constraints")), - ] - for entry in inputs - if isinstance(entry, dict) - ], + +def render_method_resources(specification_id: str) -> str: + specs, references, _ = repository_data() + spec_path, spec = specs[specification_id] + metadata = spec["metadata"] + module = metadata["module"] + algorithm = spec_path.parent.name + relative_spec_path = spec_path.relative_to(REPOSITORY_ROOT).as_posix() + case_path = f"conformance/{module}/{algorithm}" + + sections = [] + reference_ids = [] + for relationship in spec.get("provenance", {}).get("references", []): + reference_id = relationship["id"] + if reference_id not in reference_ids: + reference_ids.append(reference_id) + if reference_ids: + sections.extend(["## References", ""]) + sections.extend( + f"- {format_reference(references[reference_id])}" + for reference_id in reference_ids ) - ) + sections.append("") - parameters = normative.get("parameters", []) if isinstance(normative, dict) else [] - lines.extend(["", "## Parameters", ""]) - parameter_rows = [ + sections.extend( [ - inline_code(entry.get("id", "")), - entry.get("data_type", ""), - format_default(entry.get("default")), - entry.get("unit", ""), - format_constraints(entry.get("constraints")), + "## Implementations and technical resources", + "", + code_links(specification_id, module, algorithm), + "", + f"[Normative JSON]({REPOSITORY_URL}/blob/main/{relative_spec_path}) | " + f"[Validation cases]({REPOSITORY_URL}/tree/main/{case_path})", ] - for entry in parameters - if isinstance(entry, dict) - ] - if parameter_rows: - lines.extend(table(["id", "data_type", "default", "unit", "constraints"], parameter_rows)) - else: - lines.append("No parameters.") - - outputs = normative.get("outputs", []) if isinstance(normative, dict) else [] - lines.extend(["", "## Outputs", ""]) - lines.extend( - table( - ["id", "data_type", "shape", "unit"], - [ - [ - inline_code(entry.get("id", "")), - entry.get("data_type", ""), - entry.get("shape", ""), - entry.get("unit", ""), - ] - for entry in outputs - if isinstance(entry, dict) - ], - ) ) + return "\n".join(sections) + - definitions = normative.get("definitions", []) if isinstance(normative, dict) else [] - lines.extend(["", "## Normative Definitions", ""]) - definition_rows = [] - for definition in definitions: - if not isinstance(definition, dict): - continue - definition_rows.append( +def render_method_catalog() -> str: + specs, _, _ = repository_data() + rows = [] + for specification_id, (_, spec) in sorted(specs.items()): + rows.append( [ - inline_code(definition.get("target", "")), - definition.get("text", ""), - definition.get("latex", ""), + f"[{spec['informative']['title']}]({specification_id}.md)", + spec["metadata"]["module"].upper(), + spec["informative"]["summary"], ] ) - if definition_rows: - lines.extend(table(["Target", "Definition", "Formula"], definition_rows)) - else: - lines.append("No normative definitions are listed in this specification.") - - warnings = normative.get("warnings", []) if isinstance(normative, dict) else [] - if warnings: - lines.extend(["", "## Warnings", ""]) - lines.extend( - table( - ["id", "condition", "effect", "aggregation"], - [ - [ - inline_code(entry.get("id", "")), - entry.get("condition", ""), - entry.get("effect", ""), - entry.get("aggregation", ""), - ] - for entry in warnings - if isinstance(entry, dict) - ], - ) - ) + return table(["Method", "Area", "What it does"], rows) - behavior = normative.get("behavior", {}) if isinstance(normative, dict) else {} - lines.extend(["", "## Behavior", ""]) - if isinstance(behavior, dict) and behavior: - for key in ordered_behavior_keys(behavior): - lines.extend([f"### {title_for_key(key)}", "", str(behavior[key]), ""]) - lines.pop() - else: - lines.append("No behavior notes are listed in this specification.") - - notes = informative.get("notes", []) if isinstance(informative, dict) else [] - if notes: - lines.extend(["", "## Informative Notes", ""]) - for note in notes: - lines.append(f"* {note}") - - lines.extend(["", "## Conformance Cases", ""]) - cases = conformance_cases(root, module, algorithm) - if cases: - rows = [] - for case_path, case_id in cases: - display = relative_path(case_path, root) - rows.append( - [ - inline_code(case_id), - f"[{display}]({github_blob_url(root, case_path)})", - ] - ) - lines.extend(table(["Case ID", "File"], rows)) - else: - lines.append(f"No conformance cases were found under `conformance/{module}/{algorithm}/`.") - - return "\n".join(lines).rstrip() + "\n" - - -def generated_pages( - root: Path, - specs: list[tuple[Path, dict[str, Any]]], -) -> dict[Path, str]: - pages = {} - for spec_path, spec in specs: - metadata = spec.get("metadata", {}) - if not isinstance(metadata, dict) or not isinstance(metadata.get("id"), str): - raise ValueError(f"{relative_path(spec_path, root)} is missing metadata.id") - output_path = root / GENERATED_SPEC_DIR / f"{metadata['id']}.md" - pages[output_path] = render_spec_page(root, spec_path, spec) - return pages - - -def generated_documentation(root: Path) -> dict[Path, str]: - specs = discover_specs(root) - files = generated_pages(root, specs) - - index_path = root / SPECIFICATION_INDEX_PATH - index_source = index_path.read_text(encoding="utf-8") - files[index_path] = replace_generated_block( - index_source, - SPECIFICATION_TABLE_START, - SPECIFICATION_TABLE_END, - specification_index_lines(specs), - relative_path(index_path, root), - ) - - mkdocs_path = root / MKDOCS_PATH - mkdocs_source = mkdocs_path.read_text(encoding="utf-8") - files[mkdocs_path] = replace_generated_block( - mkdocs_source, - SPECIFICATION_NAV_START, - SPECIFICATION_NAV_END, - specification_navigation_lines(specs), - relative_path(mkdocs_path, root), - ) - return files - - -def check_generated(root: Path, pages: dict[Path, str]) -> int: - expected_paths = set(pages) - existing_paths = set((root / GENERATED_SPEC_DIR).glob("*.md")) - stale_paths = sorted(expected_paths | existing_paths) - - failures = [] - for path in stale_paths: - expected = pages.get(path) - if expected is None: - failures.append(f"stale generated file: {relative_path(path, root)}") - continue - if not path.exists(): - failures.append(f"missing generated file: {relative_path(path, root)}") - continue - actual = path.read_text(encoding="utf-8") - if actual != expected: - failures.append(f"outdated generated file: {relative_path(path, root)}") - - if failures: - print("Generated documentation is stale. Run `python tools/generate_docs.py`.") - for failure in failures: - print(f"- {failure}") - return 1 - - print("Generated documentation is up to date.") - return 0 - - -def write_generated(root: Path, pages: dict[Path, str]) -> int: - output_dir = root / GENERATED_SPEC_DIR - output_dir.mkdir(parents=True, exist_ok=True) - - expected_paths = set(pages) - for path, content in sorted(pages.items()): - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8", newline="\n") - print(f"Wrote {relative_path(path, root)}") - - for path in sorted(output_dir.glob("*.md")): - if path not in expected_paths: - path.unlink() - print(f"Removed stale {relative_path(path, root)}") - - return 0 - - -def build_argument_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Generate MkDocs documentation from Biosiglib JSON specifications.", - ) - parser.add_argument( - "--check", - action="store_true", - help="Check that generated pages, the index, and navigation are up to date.", - ) - return parser - - -def main(argv: list[str] | None = None) -> int: - args = build_argument_parser().parse_args(argv) - root = find_repository_root() - try: - pages = generated_documentation(root) - except (OSError, json.JSONDecodeError, ValueError) as exc: - print(f"Documentation generation failed: {exc}", file=sys.stderr) - return 1 +def replace_marker(markdown: str, marker: str, rendered: str) -> str: + if markdown.count(marker) != 1: + raise ValueError(f"Expected exactly one {marker!r} marker") + return markdown.replace(marker, rendered) - if args.check: - return check_generated(root, pages) - return write_generated(root, pages) +def on_page_markdown(markdown: str, page: Any, **_: Any) -> str: + """MkDocs hook: inject generated data without changing source Markdown.""" -if __name__ == "__main__": - sys.exit(main()) + source_uri = page.file.src_uri.replace("\\", "/") + if source_uri == "methods/index.md": + return replace_marker(markdown, METHOD_CATALOG_MARKER, render_method_catalog()) + if source_uri.startswith("methods/") and source_uri.endswith(".md"): + specification_id = Path(source_uri).stem + if specification_id in repository_data()[0]: + rendered = replace_marker( + markdown, + METHOD_INTERFACE_MARKER, + render_method_interface(specification_id), + ) + return replace_marker( + rendered, + METHOD_RESOURCES_MARKER, + render_method_resources(specification_id), + ) + return markdown diff --git a/tools/validate_specs.py b/tools/validate_specs.py index 32dd01c..49b2d6e 100644 --- a/tools/validate_specs.py +++ b/tools/validate_specs.py @@ -14,8 +14,10 @@ from jsonschema import Draft202012Validator -SCIENTIFIC_NOTE_DIR = Path("docs") / "scientific" -SCIENTIFIC_NOTE_SUPPORT_PAGES = {"index.md", "template.md"} +METHOD_DOC_DIR = Path("docs") / "methods" +METHOD_INTERFACE_MARKER = "" +METHOD_RESOURCES_MARKER = "" +METHOD_CATALOG_MARKER = "" SNAKE_CASE_IDENTIFIER_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") DOT_CASE_IDENTIFIER_RE = re.compile( r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:_[a-z0-9]+)*)*$" @@ -378,127 +380,124 @@ def parse_markdown_front_matter(path: Path, root: Path) -> tuple[dict[str, str], return front_matter, errors -def scientific_note_paths(root: Path) -> list[Path]: - scientific_dir = root / SCIENTIFIC_NOTE_DIR - if not scientific_dir.is_dir(): - return [] - - return [ - path - for path in sorted(scientific_dir.rglob("*.md")) - if path.name not in SCIENTIFIC_NOTE_SUPPORT_PAGES - ] - - -def is_scientific_note_discoverable(note_path: Path, root: Path) -> bool: - scientific_dir = root / SCIENTIFIC_NOTE_DIR - index_path = scientific_dir / "index.md" - mkdocs_path = root / "mkdocs.yml" - docs_relative = note_path.relative_to(root / "docs").as_posix() - scientific_relative = note_path.relative_to(scientific_dir).as_posix() - repository_relative = note_path.relative_to(root).as_posix() - - search_targets = [ - docs_relative, - scientific_relative, - repository_relative, - ] - - for source_path in [index_path, mkdocs_path]: - if not source_path.is_file(): - continue - try: - source_text = read_text(source_path) - except OSError: - continue - if any(target in source_text for target in search_targets): - return True - - return False - - -def validate_scientific_notes( +def validate_method_documentation( root: Path, specs_by_id: dict[str, dict[str, Any]], ) -> list[str]: + """Require one complete, navigable public page for every method contract.""" errors = [] - scientific_dir = root / SCIENTIFIC_NOTE_DIR - index_path = scientific_dir / "index.md" - mkdocs_path = root / "mkdocs.yml" - - if not scientific_dir.exists(): - return errors - - if not index_path.is_file(): - errors.append(f"{relative_name(index_path, root)}: scientific-note index page is missing") - - if mkdocs_path.is_file(): - mkdocs_text = read_text(mkdocs_path) - if "scientific/index.md" not in mkdocs_text: - errors.append("mkdocs.yml: scientific-note index page is not listed in navigation") - - for note_path in scientific_note_paths(root): - print(f"Validating {relative_name(note_path, root)}") - front_matter, front_matter_errors = parse_markdown_front_matter(note_path, root) - errors.extend(front_matter_errors) - - spec_id = front_matter.get("spec_id") - if not spec_id: - errors.append(f"{relative_name(note_path, root)}: front matter is missing spec_id") - elif spec_id not in specs_by_id: - errors.append( - f"{relative_name(note_path, root)}: front matter spec_id " - f"'{spec_id}' does not match an existing specification" - ) - - if not is_scientific_note_discoverable(note_path, root): - errors.append( - f"{relative_name(note_path, root)}: scientific note is not discoverable from " - "docs/scientific/index.md or mkdocs.yml" - ) - - return errors - - -def validate_specification_documentation( - root: Path, - specs_by_id: dict[str, dict[str, Any]], -) -> list[str]: - """Require every specification page to be generated, indexed, and navigable.""" - errors = [] - index_path = root / "docs" / "specifications.md" + method_dir = root / METHOD_DOC_DIR + index_path = method_dir / "index.md" + descriptions_path = method_dir / "descriptions.json" mkdocs_path = root / "mkdocs.yml" index_text = read_text(index_path) if index_path.is_file() else None mkdocs_text = read_text(mkdocs_path) if mkdocs_path.is_file() else None if index_text is None: - errors.append(f"{relative_name(index_path, root)}: specification index page is missing") + errors.append(f"{relative_name(index_path, root)}: method index page is missing") + elif index_text.count(METHOD_CATALOG_MARKER) != 1: + errors.append( + f"{relative_name(index_path, root)}: expected exactly one method catalog marker" + ) if mkdocs_text is None: errors.append(f"{relative_name(mkdocs_path, root)}: MkDocs configuration is missing") + elif "tools/generate_docs.py" not in mkdocs_text: + errors.append("mkdocs.yml: method documentation hook is missing") - for specification_id in sorted(specs_by_id): - documentation_path = ( - root / "docs" / "generated" / "specifications" / f"{specification_id}.md" + descriptions: dict[str, Any] = {} + if not descriptions_path.is_file(): + errors.append(f"{relative_name(descriptions_path, root)}: field descriptions are missing") + else: + try: + loaded_descriptions = load_json(descriptions_path) + if isinstance(loaded_descriptions, dict): + descriptions = loaded_descriptions + else: + errors.append( + f"{relative_name(descriptions_path, root)}: expected a JSON object" + ) + except (OSError, json.JSONDecodeError) as error: + errors.append(f"{relative_name(descriptions_path, root)}: {error}") + + unknown_description_ids = sorted(set(descriptions) - set(specs_by_id)) + for specification_id in unknown_description_ids: + errors.append( + f"{relative_name(descriptions_path, root)}: descriptions for unknown method " + f"'{specification_id}'" ) - documentation_reference = f"generated/specifications/{specification_id}.md" + + for specification_id, spec in sorted(specs_by_id.items()): + documentation_path = method_dir / f"{specification_id}.md" + documentation_reference = f"methods/{specification_id}.md" if not documentation_path.is_file(): errors.append( f"{relative_name(documentation_path, root)}: " - f"generated page for specification '{specification_id}' is missing" + f"method page for '{specification_id}' is missing" ) - if index_text is not None and documentation_reference not in index_text: - errors.append( - f"{relative_name(index_path, root)}: specification " - f"'{specification_id}' is not listed" + else: + print(f"Validating {relative_name(documentation_path, root)}") + documentation_text = read_text(documentation_path) + front_matter, front_matter_errors = parse_markdown_front_matter( + documentation_path, root ) + errors.extend(front_matter_errors) + if front_matter.get("spec_id") != specification_id: + errors.append( + f"{relative_name(documentation_path, root)}: front matter spec_id must be " + f"'{specification_id}'" + ) + for marker, label in ( + (METHOD_INTERFACE_MARKER, "method interface"), + (METHOD_RESOURCES_MARKER, "method resources"), + ): + if documentation_text.count(marker) != 1: + errors.append( + f"{relative_name(documentation_path, root)}: expected exactly one " + f"{label} marker" + ) + if mkdocs_text is not None and documentation_reference not in mkdocs_text: errors.append( - f"{relative_name(mkdocs_path, root)}: specification " + f"{relative_name(mkdocs_path, root)}: method " f"'{specification_id}' is not listed in navigation" ) + expected_fields = set().union( + spec.get("input_ids", set()), + spec.get("parameter_ids", set()), + spec.get("output_ids", set()), + ) + method_descriptions = descriptions.get(specification_id) + if not isinstance(method_descriptions, dict): + errors.append( + f"{relative_name(descriptions_path, root)}: descriptions for method " + f"'{specification_id}' are missing or invalid" + ) + else: + actual_fields = set(method_descriptions) + for field_id in sorted(expected_fields - actual_fields): + errors.append( + f"{relative_name(descriptions_path, root)}: method '{specification_id}' " + f"is missing a description for '{field_id}'" + ) + for field_id in sorted(actual_fields - expected_fields): + errors.append( + f"{relative_name(descriptions_path, root)}: method '{specification_id}' " + f"describes unknown field '{field_id}'" + ) + + if method_dir.is_dir(): + expected_pages = {f"{specification_id}.md" for specification_id in specs_by_id} + extra_pages = { + path.name for path in method_dir.glob("*.md") + } - expected_pages - {"index.md"} + for page_name in sorted(extra_pages): + errors.append( + f"{relative_name(method_dir / page_name, root)}: no matching specification" + ) + return errors @@ -1041,8 +1040,7 @@ def validate_repository(root: Path) -> tuple[list[str], dict[str, dict[str, Any] known_reference_ids, ) ) - errors.extend(validate_scientific_notes(root, specs_by_id)) - errors.extend(validate_specification_documentation(root, specs_by_id)) + errors.extend(validate_method_documentation(root, specs_by_id)) return errors, specs_by_id, validators