Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ uv.lock

# Quarto
docs/_site/

# created by quartodoc
docs/api
docs/.quarto/
docs/**/*.quarto_ipynb*
docs/api/*.qmd
!docs/api/index.qmd
!docs/api/_metadata.yml
# Generated from notebooks/ by docs/scripts/generate_examples_from_notebooks.py
docs/examples/*.ipynb
docs/examples/*_files/

# created by quartodoc
10 changes: 6 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
LIB = src/tsod

.PHONY: check build lint format test coverage docs clean
.PHONY: check build lint format test coverage docs examples clean

check: lint test

Expand All @@ -19,13 +19,15 @@ test:
coverage:
uv run pytest --cov-report html --cov=$(LIB) tests/

examples:
$(MAKE) -C docs examples

docs:
cd docs && uv run quartodoc build
uv run quarto render docs
$(MAKE) -C docs build

clean:
rm -rf .pytest_cache
rm -rf .mypy_cache
rm -rf .coverage
rm -rf dist
rm -rf docs/_build
$(MAKE) -C docs clean
2 changes: 0 additions & 2 deletions docs/.gitignore

This file was deleted.

14 changes: 10 additions & 4 deletions docs/Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Minimal makefile for Quarto documentation
#

.PHONY: help api build preview clean
.PHONY: help api examples build preview clean

help:
@echo "Please use 'make <target>' where <target> is one of:"
Expand All @@ -13,11 +13,17 @@ help:
api:
uv run quartodoc build

build: api
examples:
uv run python scripts/generate_examples_from_notebooks.py

build: api examples
uv run quarto render

preview: api
preview: api examples
uv run quarto preview

clean:
rm -rf _site api objects.json
rm -rf _site .quarto objects.json
find api -name "*.qmd" ! -name "index.qmd" -delete
rm -f examples/*.ipynb
rm -rf examples/*_files
40 changes: 30 additions & 10 deletions docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ project:
type: website

website:
title: "tsod"
title: ""
page-footer: "© 2025 DHI Group"
repo-url: https://github.com/DHI/tsod
repo-actions: [edit]
repo-subdir: docs
page-navigation: true

navbar:
logo: https://raw.githubusercontent.com/DHI/tsod/main/images/logo/tsod.png
tools:
- icon: github
menu:
Expand All @@ -17,13 +19,30 @@ website:
- text: Report a Bug
url: https://github.com/DHI/tsod/issues
left:
- href: index.qmd
text: Home
- href: getting-started.qmd
text: Getting Started
- href: design.qmd
- href: api/index.qmd
text: API Reference
- text: Home
href: index.qmd
- text: User Guide
href: user-guide/getting-started.qmd
- text: Examples
href: examples/index.qmd
- text: API Reference
href: api/index.qmd

sidebar:
- title: "User Guide"
style: docked
contents:
- user-guide/getting-started.qmd
- user-guide/design.qmd
- title: "Examples"
style: docked
contents:
- examples/index.qmd
# BEGIN_GENERATED_EXAMPLES — managed by docs/scripts/generate_examples_from_notebooks.py
- examples/Getting started.ipynb
- examples/Example Water Level.ipynb
- examples/Detect on DataFrames.ipynb
# END_GENERATED_EXAMPLES

filters:
- interlinks
Expand Down Expand Up @@ -63,6 +82,7 @@ quartodoc:
format:
html:
theme: cosmo
css: custom.css
toc: true
ipynb:
toc: true
# ipynb:
# toc: true
17 changes: 17 additions & 0 deletions docs/api/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# API Reference {.doc .doc-index}

## tsod



| | |
| --- | --- |
| [RangeDetector](RangeDetector.qmd#tsod.RangeDetector) | Detect values outside range. |
| [ConstantValueDetector](ConstantValueDetector.qmd#tsod.ConstantValueDetector) | Detect contiguous periods of constant values within a configurable time window. |
| [ConstantGradientDetector](ConstantGradientDetector.qmd#tsod.ConstantGradientDetector) | Detect constant gradients. |
| [GradientDetector](GradientDetector.qmd#tsod.GradientDetector) | Detect abrupt changes in time series data. |
| [DiffDetector](DiffDetector.qmd#tsod.DiffDetector) | Detect sudden shifts in data, irrespective of time axis. |
| [RollingStandardDeviationDetector](RollingStandardDeviationDetector.qmd#tsod.RollingStandardDeviationDetector) | Detect large variations. |
| [CombinedDetector](CombinedDetector.qmd#tsod.CombinedDetector) | Combine detectors. |
| [HampelDetector](HampelDetector.qmd#tsod.HampelDetector) | Hampel filter implementation that works on numpy arrays, implemented with numba. |
| [load](load.qmd#tsod.load) | Load a saved model from disk saved with `Detector.save` |
7 changes: 7 additions & 0 deletions docs/custom.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#quarto-content.page-layout-full main.content.column-body > #title-block-header + p {
display: none;
}

#quarto-content.page-layout-full main.content.column-body {
max-width: min(1600px, calc(100vw - 4rem));
}
17 changes: 17 additions & 0 deletions docs/examples/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: Examples
page-layout: full
toc: false
---

# Examples

This page is auto-generated from notebooks in `notebooks/`.

## Available notebook examples

- [Getting started](Getting%20started.ipynb)
- [Example Water Level](Example%20Water%20Level.ipynb)
- [Detect on DataFrames](Detect%20on%20DataFrames.ipynb)

Regenerate with `make examples`.
4 changes: 3 additions & 1 deletion docs/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ format-links: false
Install **tsod** with [`pip`](https://pypi.org/project/tsod/) and get up
and running in minutes


[**Getting started**](user-guide/getting-started.qmd)

## {{< fa brands python >}} **It's just Python**

Use familiar Python workflows to integrate anomaly detection into your models and pipelines

[**API Reference**](api/index.qmd)

:::

Expand All @@ -40,6 +41,7 @@ Choose from detectors like `RangeDetector` and `ConstantValueDetector` to identi

**tsod** is licensed under MIT and the source code is available on [GitHub](https://github.com/DHI/tsod)

[**Design philosophy**](user-guide/design.qmd)

:::

Expand Down
174 changes: 174 additions & 0 deletions docs/scripts/generate_examples_from_notebooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import json
import re
from pathlib import Path
from urllib.parse import quote

REPO_ROOT = Path(__file__).resolve().parents[2]
NOTEBOOKS_DIR = REPO_ROOT / "notebooks"
EXAMPLES_DIR = REPO_ROOT / "docs" / "examples"
QUARTO_YML = REPO_ROOT / "docs" / "_quarto.yml"

_SIDEBAR_BEGIN = " # BEGIN_GENERATED_EXAMPLES — managed by docs/scripts/generate_examples_from_notebooks.py"
_SIDEBAR_END = " # END_GENERATED_EXAMPLES"
_EXAMPLE_ORDER = {
"Getting started.ipynb": 0,
"Example Water Level.ipynb": 1,
"Detect on DataFrames.ipynb": 2,
}


def sort_entries(entries: list[tuple[str, str]]) -> list[tuple[str, str]]:
"""Keep generated example pages in a stable, user-defined order."""
return sorted(
entries,
key=lambda item: (_EXAMPLE_ORDER.get(item[1], len(_EXAMPLE_ORDER)), item[0].lower()),
)


def title_from_notebook(notebook: dict, fallback: str) -> str:
metadata_title = notebook.get("metadata", {}).get("title")
if isinstance(metadata_title, str) and metadata_title.strip():
return metadata_title.strip()
return fallback


def rewrite_notebook_relative_paths(source: str) -> str:
"""Rewrite paths that are valid in notebooks/ to paths valid in docs/examples/."""
return source.replace("../tests/", "../../tests/")


def rewrite_cell_source_paths(notebook: dict) -> None:
"""Rewrite relative paths in markdown and code cell sources."""
for cell in notebook.get("cells", []):
source = cell.get("source")
if isinstance(source, str):
cell["source"] = rewrite_notebook_relative_paths(source)
continue
if isinstance(source, list):
cell["source"] = [rewrite_notebook_relative_paths(line) for line in source]


def notebook_front_matter_source(title: str, notebook_name: str) -> str:
lines = [
"---",
f"title: {title}",
f"description: Auto-generated from notebooks/{notebook_name}",
"jupyter: tsod",
"page-layout: full",
"---",
"",
"<!-- AUTO-GENERATED: run `make examples` or `make docs` from repo root. -->",
]
return "\n".join(lines) + "\n"


def apply_front_matter_cell(notebook: dict, title: str, notebook_name: str) -> None:
source = notebook_front_matter_source(title=title, notebook_name=notebook_name)
front_matter_cell = {
"cell_type": "markdown",
"metadata": {"language": "markdown", "tags": ["remove-cell"]},
"source": source,
}

cells = notebook.setdefault("cells", [])
if not cells:
cells.append(front_matter_cell)
return

first_cell = cells[0]
first_source = first_cell.get("source")
lines = first_source if isinstance(first_source, list) else [str(first_source or "")]
first_line = lines[0].strip() if lines else ""

if first_cell.get("cell_type") == "markdown" and first_line == "---":
first_cell["source"] = source
return

cells.insert(0, front_matter_cell)


def copy_notebook_to_examples(notebook_path: Path) -> tuple[str, str]:
notebook = json.loads(notebook_path.read_text(encoding="utf-8"))
stem = notebook_path.stem
title = title_from_notebook(notebook, fallback=stem)
ipynb_path = EXAMPLES_DIR / notebook_path.name

rewrite_cell_source_paths(notebook)
apply_front_matter_cell(notebook, title=title, notebook_name=notebook_path.name)

ipynb_path.write_text(json.dumps(notebook, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return title, ipynb_path.name


def write_index(entries: list[tuple[str, str]]) -> None:
index_lines = [
"---",
"title: Examples",
"page-layout: full",
"toc: false",
"---",
"",
"# Examples",
"",
"This page is auto-generated from notebooks in `notebooks/`.",
"",
"## Available notebook examples",
"",
]

for title, rel_path in sort_entries(entries):
encoded_path = quote(rel_path, safe="/")
index_lines.append(f"- [{title}]({encoded_path})")

index_lines.append("")
index_lines.append("Regenerate with `make examples`.")

(EXAMPLES_DIR / "index.qmd").write_text("\n".join(index_lines) + "\n", encoding="utf-8")


def update_quarto_sidebar(entries: list[tuple[str, str]]) -> None:
"""Keep the Examples sidebar in _quarto.yml in sync with generated notebooks."""
content = QUARTO_YML.read_text(encoding="utf-8")

lines = [_SIDEBAR_BEGIN]
for _title, ipynb_name in sort_entries(entries):
lines.append(f" - examples/{ipynb_name}")
lines.append(_SIDEBAR_END)
new_block = "\n".join(lines)

updated = re.sub(
re.escape(_SIDEBAR_BEGIN) + r".*?" + re.escape(_SIDEBAR_END),
new_block,
content,
flags=re.DOTALL,
)
QUARTO_YML.write_text(updated, encoding="utf-8")


def main() -> None:
EXAMPLES_DIR.mkdir(parents=True, exist_ok=True)

# Remove previously generated files to avoid stale pages.
for ipynb_file in EXAMPLES_DIR.glob("*.ipynb"):
ipynb_file.unlink()

for qmd_file in EXAMPLES_DIR.glob("*.qmd"):
if qmd_file.name != "index.qmd":
qmd_file.unlink()

for quarto_ipynb in EXAMPLES_DIR.glob("*.quarto_ipynb"):
quarto_ipynb.unlink()

notebook_files = sorted(NOTEBOOKS_DIR.glob("*.ipynb"))
entries: list[tuple[str, str]] = []

for notebook_path in notebook_files:
title, ipynb_name = copy_notebook_to_examples(notebook_path)
entries.append((title, ipynb_name))

write_index(entries)
update_quarto_sidebar(entries)


if __name__ == "__main__":
main()
Loading
Loading