cpssbench 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Md Hasan Shahriar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: cpssbench
3
+ Version: 0.1.0
4
+ Summary: Cyber-Physical Systems Security Bench: datasets that download, preprocess, and load like MNIST.
5
+ Author: Md Hasan Shahriar
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/shahriar0651/cpssbench
8
+ Keywords: dataset,cpss,cps,can,ids,vehicular,cybersecurity,pytorch
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.23
18
+ Requires-Dist: pandas>=2.0
19
+ Requires-Dist: pyarrow>=14
20
+ Requires-Dist: joblib>=1.3
21
+ Requires-Dist: torch>=2.0
22
+ Requires-Dist: tqdm>=4.60
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # cpssbench
28
+
29
+ Cyber-Physical Systems Security Bench. It downloads raw traces, builds windowed tensors, and returns a PyTorch dataset with the same contract as MNIST: `(window, label)`.
30
+
31
+ ```python
32
+ from cpssbench import SynCAN
33
+ from torch.utils.data import DataLoader
34
+
35
+ train = SynCAN(root="./data", split="train", download=True)
36
+ test = SynCAN(root="./data", split="test", download=True)
37
+
38
+ window, label = train[0] # window: (1, time, signals), label: 0 benign / 1 attack
39
+ loader = DataLoader(train, batch_size=64, shuffle=True)
40
+ ```
41
+
42
+ Or load by name:
43
+
44
+ ```python
45
+ import cpssbench
46
+
47
+ dataset = cpssbench.load("road", root="./data", split="test", download=True)
48
+ print(dataset.input_shape) # (channels, window, signals)
49
+ ```
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install cpssbench
55
+ ```
56
+
57
+ That works after the package is published on PyPI. Until then, install this repository directly:
58
+
59
+ ```bash
60
+ pip install "git+https://github.com/shahriar0651/cpssbench.git"
61
+ ```
62
+
63
+ From a local clone, for development:
64
+
65
+ ```bash
66
+ pip install -e .
67
+ ```
68
+
69
+ Then open `examples/train_like_mnist.ipynb` to plot a sample grid and train a small network.
70
+
71
+ Python 3.10+. SynCAN also needs `git` on `PATH`. ROAD is fetched from Zenodo with the standard library, so `wget` is not required.
72
+
73
+ ## Datasets
74
+
75
+ | Name | Status | What you get |
76
+ | --- | --- | --- |
77
+ | `syncan` | ready, auto-download | Synthetic CAN intrusion traces |
78
+ | `road` | ready, auto-download | ROAD dynamometer CAN traces |
79
+ | `misbehaviorx` | loader ready, manual files | V2X misbehavior (also accepted as `vasp`) |
80
+ | `x-canids` | registered, not implemented | Raises a clear error until a loader is added |
81
+
82
+ ```bash
83
+ python -m cpssbench list
84
+ python -m cpssbench info syncan
85
+ python -m cpssbench download syncan --root ./data --split train
86
+ ```
87
+
88
+ Downloaded files land in `<root>/<name>/{ambient,attacks}` plus a fitted min/max scaler under `<root>/<name>/scaler`. Later calls reuse those files.
89
+
90
+ ## Overrides
91
+
92
+ Windowing defaults live in the library so a new project does not need the old Hydra YAML. Override them per call:
93
+
94
+ ```python
95
+ from cpssbench import ROAD
96
+
97
+ dataset = ROAD(root="./data", split="train", download=True, window_size=50, step_size=5)
98
+ ```
99
+
100
+ Pass `return_meta=True` if you also need the source file and row index: `(window, label, {"file", "idx"})`.
101
+
102
+ ## Layout
103
+
104
+ Each sample is a min-max scaled window with a channel axis, so the same convolutional IDS can run on every dataset. Shape is always `(channels, window_size, num_signals)`. Label `0` is benign and `1` is attack (any attack flag inside the window).
105
+
106
+ ## Adding a dataset
107
+
108
+ 1. Add a `DatasetSpec` in `src/cpssbench/specs.py`.
109
+ 2. Add a downloader in `src/cpssbench/download.py` if the files can be fetched automatically.
110
+ 3. Register the class in `src/cpssbench/datasets.py` and `_CLASSES` in `__init__.py`.
111
+
112
+ The IDS experiments that consume this package live in the sibling [RobIDS](https://github.com/shahriar0651/robids) repo.
@@ -0,0 +1,86 @@
1
+ # cpssbench
2
+
3
+ Cyber-Physical Systems Security Bench. It downloads raw traces, builds windowed tensors, and returns a PyTorch dataset with the same contract as MNIST: `(window, label)`.
4
+
5
+ ```python
6
+ from cpssbench import SynCAN
7
+ from torch.utils.data import DataLoader
8
+
9
+ train = SynCAN(root="./data", split="train", download=True)
10
+ test = SynCAN(root="./data", split="test", download=True)
11
+
12
+ window, label = train[0] # window: (1, time, signals), label: 0 benign / 1 attack
13
+ loader = DataLoader(train, batch_size=64, shuffle=True)
14
+ ```
15
+
16
+ Or load by name:
17
+
18
+ ```python
19
+ import cpssbench
20
+
21
+ dataset = cpssbench.load("road", root="./data", split="test", download=True)
22
+ print(dataset.input_shape) # (channels, window, signals)
23
+ ```
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install cpssbench
29
+ ```
30
+
31
+ That works after the package is published on PyPI. Until then, install this repository directly:
32
+
33
+ ```bash
34
+ pip install "git+https://github.com/shahriar0651/cpssbench.git"
35
+ ```
36
+
37
+ From a local clone, for development:
38
+
39
+ ```bash
40
+ pip install -e .
41
+ ```
42
+
43
+ Then open `examples/train_like_mnist.ipynb` to plot a sample grid and train a small network.
44
+
45
+ Python 3.10+. SynCAN also needs `git` on `PATH`. ROAD is fetched from Zenodo with the standard library, so `wget` is not required.
46
+
47
+ ## Datasets
48
+
49
+ | Name | Status | What you get |
50
+ | --- | --- | --- |
51
+ | `syncan` | ready, auto-download | Synthetic CAN intrusion traces |
52
+ | `road` | ready, auto-download | ROAD dynamometer CAN traces |
53
+ | `misbehaviorx` | loader ready, manual files | V2X misbehavior (also accepted as `vasp`) |
54
+ | `x-canids` | registered, not implemented | Raises a clear error until a loader is added |
55
+
56
+ ```bash
57
+ python -m cpssbench list
58
+ python -m cpssbench info syncan
59
+ python -m cpssbench download syncan --root ./data --split train
60
+ ```
61
+
62
+ Downloaded files land in `<root>/<name>/{ambient,attacks}` plus a fitted min/max scaler under `<root>/<name>/scaler`. Later calls reuse those files.
63
+
64
+ ## Overrides
65
+
66
+ Windowing defaults live in the library so a new project does not need the old Hydra YAML. Override them per call:
67
+
68
+ ```python
69
+ from cpssbench import ROAD
70
+
71
+ dataset = ROAD(root="./data", split="train", download=True, window_size=50, step_size=5)
72
+ ```
73
+
74
+ Pass `return_meta=True` if you also need the source file and row index: `(window, label, {"file", "idx"})`.
75
+
76
+ ## Layout
77
+
78
+ Each sample is a min-max scaled window with a channel axis, so the same convolutional IDS can run on every dataset. Shape is always `(channels, window_size, num_signals)`. Label `0` is benign and `1` is attack (any attack flag inside the window).
79
+
80
+ ## Adding a dataset
81
+
82
+ 1. Add a `DatasetSpec` in `src/cpssbench/specs.py`.
83
+ 2. Add a downloader in `src/cpssbench/download.py` if the files can be fetched automatically.
84
+ 3. Register the class in `src/cpssbench/datasets.py` and `_CLASSES` in `__init__.py`.
85
+
86
+ The IDS experiments that consume this package live in the sibling [RobIDS](https://github.com/shahriar0651/robids) repo.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cpssbench"
7
+ version = "0.1.0"
8
+ description = "Cyber-Physical Systems Security Bench: datasets that download, preprocess, and load like MNIST."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Md Hasan Shahriar" }]
13
+ keywords = ["dataset", "cpss", "cps", "can", "ids", "vehicular", "cybersecurity", "pytorch"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ ]
21
+ dependencies = [
22
+ "numpy>=1.23",
23
+ "pandas>=2.0",
24
+ "pyarrow>=14",
25
+ "joblib>=1.3",
26
+ "torch>=2.0",
27
+ "tqdm>=4.60",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=7"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/shahriar0651/cpssbench"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.setuptools.package-data]
40
+ cpssbench = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from setuptools import setup
2
+
3
+ setup()
@@ -0,0 +1,76 @@
1
+ """cpssbench: Cyber-Physical Systems Security Bench, loaded like MNIST.
2
+
3
+ Example::
4
+
5
+ from cpssbench import SynCAN
6
+ from torch.utils.data import DataLoader
7
+
8
+ train = SynCAN(root="./data", split="train", download=True)
9
+ window, label = train[0]
10
+ loader = DataLoader(train, batch_size=64, shuffle=True)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from .datasets import MisbehaviorX, ROAD, SynCAN, VehicularDataset
18
+ from .specs import DatasetSpec, get_spec, list_specs
19
+
20
+ __all__ = [
21
+ "SynCAN",
22
+ "ROAD",
23
+ "MisbehaviorX",
24
+ "VehicularDataset",
25
+ "load",
26
+ "list_datasets",
27
+ "describe",
28
+ "get_spec",
29
+ ]
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ _CLASSES = {
34
+ "syncan": SynCAN,
35
+ "road": ROAD,
36
+ "misbehaviorx": MisbehaviorX,
37
+ "vasp": MisbehaviorX,
38
+ "veremi": MisbehaviorX,
39
+ }
40
+
41
+
42
+ def list_datasets(status: Optional[str] = None) -> list[dict]:
43
+ """Return name, status, family, and shape for every registered dataset."""
44
+ rows = []
45
+ for spec in list_specs(status):
46
+ rows.append(
47
+ {
48
+ "name": spec.name,
49
+ "status": spec.status,
50
+ "family": spec.family,
51
+ "input_shape": spec.input_shape if spec.features else None,
52
+ "downloadable": spec.downloadable,
53
+ "description": spec.description,
54
+ }
55
+ )
56
+ return rows
57
+
58
+
59
+ def describe(name: str) -> DatasetSpec:
60
+ return get_spec(name)
61
+
62
+
63
+ def load(name: str, **kwargs) -> VehicularDataset:
64
+ """Load a dataset by name.
65
+
66
+ ``kwargs`` are forwarded to the dataset class (``root``, ``split``,
67
+ ``download``, ``window_size``, ...).
68
+ """
69
+ key = name.strip().lower()
70
+ if key == "x-canids":
71
+ spec = get_spec(key)
72
+ raise NotImplementedError(f"{spec.name} is not implemented yet. {spec.notes}")
73
+ if key not in _CLASSES:
74
+ known = ", ".join(sorted(set(_CLASSES) | {"x-canids"}))
75
+ raise KeyError(f"Unknown dataset '{name}'. Known datasets: {known}")
76
+ return _CLASSES[key](**kwargs)
@@ -0,0 +1,38 @@
1
+ """Small command-line helper: ``python -m cpssbench list``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from . import describe, list_datasets, load
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(description="Download and inspect vehicular IDS datasets.")
12
+ sub = parser.add_subparsers(dest="command", required=True)
13
+
14
+ sub.add_parser("list", help="List registered datasets")
15
+
16
+ info = sub.add_parser("info", help="Show one dataset specification")
17
+ info.add_argument("name")
18
+
19
+ fetch = sub.add_parser("download", help="Download and preprocess a split")
20
+ fetch.add_argument("name")
21
+ fetch.add_argument("--root", default="./data")
22
+ fetch.add_argument("--split", default="train", choices=["train", "test"])
23
+
24
+ args = parser.parse_args()
25
+ if args.command == "list":
26
+ for row in list_datasets():
27
+ shape = row["input_shape"] or "n/a"
28
+ print(f"{row['name']:16} {row['status']:8} {row['family']:4} {shape}")
29
+ elif args.command == "info":
30
+ spec = describe(args.name)
31
+ print(spec)
32
+ else:
33
+ dataset = load(args.name, root=args.root, split=args.split, download=True, verbose=True)
34
+ print(f"Loaded {len(dataset)} windows with shape {dataset.input_shape}")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
@@ -0,0 +1,130 @@
1
+ """Public dataset classes.
2
+
3
+ Usage matches torchvision:
4
+
5
+ from cpssbench import SynCAN
6
+
7
+ train = SynCAN(root="./data", split="train", download=True)
8
+ window, label = train[0]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from torch.utils.data import Dataset
17
+
18
+ from .download import dataset_root, ensure_downloaded, split_dir
19
+ from .preprocess import prepare_can_split
20
+ from .specs import DatasetSpec, get_spec
21
+ from .v2x import V2XWindowDataset, prepare_v2x_split
22
+ from .windows import WindowDataset
23
+
24
+
25
+ class VehicularDataset(Dataset):
26
+ """Base class for datasets that download, preprocess, and yield windows."""
27
+
28
+ spec_name: str = ""
29
+
30
+ def __init__(
31
+ self,
32
+ root: str | Path = "./data",
33
+ split: str = "train",
34
+ download: bool = True,
35
+ transform=None,
36
+ target_transform=None,
37
+ window_size: Optional[int] = None,
38
+ step_size: Optional[int] = None,
39
+ sampling_period: Optional[int] = None,
40
+ return_meta: bool = False,
41
+ data_dir: Optional[str | Path] = None,
42
+ scaler_dir: Optional[str | Path] = None,
43
+ n_jobs: Optional[int] = None,
44
+ verbose: bool = False,
45
+ ) -> None:
46
+ spec = get_spec(self.spec_name).with_overrides(window_size, step_size, sampling_period)
47
+ if spec.status == "planned":
48
+ raise NotImplementedError(f"{spec.name} is not implemented yet. {spec.notes}")
49
+
50
+ self.spec = spec
51
+ self.root = Path(root).expanduser().resolve()
52
+ self.split = split
53
+ self.transform = transform
54
+ self.target_transform = target_transform
55
+ self.return_meta = return_meta
56
+
57
+ if data_dir is None and download:
58
+ ensure_downloaded(spec, self.root)
59
+
60
+ self.data_dir = Path(data_dir) if data_dir is not None else split_dir(self.root, spec.name, split)
61
+ if scaler_dir is not None:
62
+ self.scaler_path = Path(scaler_dir) / f"min_max_values_{spec.name}.csv"
63
+ else:
64
+ self.scaler_path = dataset_root(self.root, spec.name) / "scaler" / f"min_max_values_{spec.name}.csv"
65
+
66
+ fit_scaler = split.strip().lower() in {"train", "training", "ambient"}
67
+ if spec.family == "can":
68
+ prepare_can_split(spec, self.data_dir, self.scaler_path, fit_scaler, n_jobs=n_jobs)
69
+ self._base = WindowDataset(spec, self.data_dir, self.scaler_path, return_meta, verbose)
70
+ elif spec.family == "v2x":
71
+ prepare_v2x_split(spec, self.data_dir, self.scaler_path, fit_scaler)
72
+ self._base = V2XWindowDataset(spec, self.data_dir, self.scaler_path, return_meta, verbose)
73
+ else:
74
+ raise ValueError(f"Unsupported dataset family: {spec.family}")
75
+
76
+ def __len__(self) -> int:
77
+ return len(self._base)
78
+
79
+ def __getitem__(self, idx: int):
80
+ item = self._base[idx]
81
+ window, label = item[0], item[1]
82
+ if self.transform is not None:
83
+ window = self.transform(window)
84
+ if self.target_transform is not None:
85
+ label = self.target_transform(label)
86
+ if self.return_meta:
87
+ return window, label, item[2]
88
+ return window, label
89
+
90
+ @property
91
+ def classes(self) -> tuple[str, ...]:
92
+ return self.spec.classes
93
+
94
+ @property
95
+ def input_shape(self) -> tuple[int, int, int]:
96
+ return self.spec.input_shape
97
+
98
+ @property
99
+ def num_signals(self) -> int:
100
+ return self.spec.num_signals
101
+
102
+ @property
103
+ def window_size(self) -> int:
104
+ return self.spec.window_size
105
+
106
+ @property
107
+ def channels(self) -> int:
108
+ return self.spec.channels
109
+
110
+ @property
111
+ def features(self) -> tuple[str, ...]:
112
+ return self.spec.features
113
+
114
+
115
+ class SynCAN(VehicularDataset):
116
+ """SynCAN intrusion dataset. ``split`` is ``'train'`` or ``'test'``."""
117
+
118
+ spec_name = "syncan"
119
+
120
+
121
+ class ROAD(VehicularDataset):
122
+ """ROAD CAN intrusion dataset. ``split`` is ``'train'`` or ``'test'``."""
123
+
124
+ spec_name = "road"
125
+
126
+
127
+ class MisbehaviorX(VehicularDataset):
128
+ """V2X misbehavior dataset. Requires a local copy; download is manual."""
129
+
130
+ spec_name = "misbehaviorx"
@@ -0,0 +1,160 @@
1
+ """Download raw vehicular datasets into a local root directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import urllib.request
8
+ import zipfile
9
+ from pathlib import Path
10
+
11
+ from .specs import DatasetSpec
12
+
13
+
14
+ class DownloadError(RuntimeError):
15
+ """Raised when a dataset cannot be fetched or unpacked."""
16
+
17
+
18
+ def _require_tool(name: str) -> None:
19
+ if shutil.which(name) is None:
20
+ raise DownloadError(
21
+ f"'{name}' is required to download this dataset but was not found on PATH."
22
+ )
23
+
24
+
25
+ def _download_file(url: str, destination: Path) -> None:
26
+ destination.parent.mkdir(parents=True, exist_ok=True)
27
+ print(f"Downloading {url}")
28
+ try:
29
+ urllib.request.urlretrieve(url, destination)
30
+ except Exception as exc:
31
+ raise DownloadError(f"Failed to download {url}: {exc}") from exc
32
+
33
+
34
+ def split_dirname(split: str) -> str:
35
+ key = split.strip().lower()
36
+ mapping = {
37
+ "train": "ambient",
38
+ "training": "ambient",
39
+ "ambient": "ambient",
40
+ "test": "attacks",
41
+ "testing": "attacks",
42
+ "attack": "attacks",
43
+ "attacks": "attacks",
44
+ }
45
+ if key not in mapping:
46
+ raise ValueError(
47
+ f"Unsupported split '{split}'. Use 'train'/'ambient' or 'test'/'attacks'."
48
+ )
49
+ return mapping[key]
50
+
51
+
52
+ def dataset_root(root: str | Path, name: str) -> Path:
53
+ return Path(root).expanduser().resolve() / name
54
+
55
+
56
+ def split_dir(root: str | Path, name: str, split: str) -> Path:
57
+ return dataset_root(root, name) / split_dirname(split)
58
+
59
+
60
+ def raw_csvs_present(path: Path) -> bool:
61
+ return path.is_dir() and any(path.glob("*.csv"))
62
+
63
+
64
+ def download_syncan(destination: Path) -> None:
65
+ """Clone SynCAN and unpack ambient/attacks next to each other."""
66
+ _require_tool("git")
67
+ destination.mkdir(parents=True, exist_ok=True)
68
+ if raw_csvs_present(destination / "ambient") and raw_csvs_present(destination / "attacks"):
69
+ print(f"SynCAN already present at {destination}")
70
+ return
71
+
72
+ clone_dir = destination / "_raw"
73
+ if clone_dir.exists():
74
+ shutil.rmtree(clone_dir)
75
+ print(f"Cloning SynCAN into {clone_dir}")
76
+ subprocess.run(
77
+ ["git", "clone", "--depth", "1", "https://github.com/etas/SynCAN.git", str(clone_dir)],
78
+ check=True,
79
+ )
80
+
81
+ for pattern, folder in (("train_*.zip", "ambient"), ("test_*.zip", "attacks")):
82
+ zips = sorted(clone_dir.glob(pattern))
83
+ if not zips:
84
+ raise DownloadError(f"SynCAN clone is missing {pattern} archives.")
85
+ out = destination / folder
86
+ out.mkdir(parents=True, exist_ok=True)
87
+ for archive in zips:
88
+ print(f"Extracting {archive.name} -> {out}")
89
+ with zipfile.ZipFile(archive) as zf:
90
+ zf.extractall(out)
91
+
92
+ for normal in (destination / "attacks").glob("test_normal*"):
93
+ if normal.is_file():
94
+ normal.unlink()
95
+ shutil.rmtree(clone_dir, ignore_errors=True)
96
+ print(f"SynCAN downloaded to {destination}")
97
+
98
+
99
+ def download_road(destination: Path) -> None:
100
+ """Download the ROAD signal-extraction release from Zenodo."""
101
+ destination.mkdir(parents=True, exist_ok=True)
102
+ if raw_csvs_present(destination / "ambient") and raw_csvs_present(destination / "attacks"):
103
+ print(f"ROAD already present at {destination}")
104
+ return
105
+
106
+ archive = destination / "road.zip"
107
+ _download_file("https://zenodo.org/records/10462796/files/road.zip", archive)
108
+ extract_root = destination / "_raw"
109
+ if extract_root.exists():
110
+ shutil.rmtree(extract_root)
111
+ extract_root.mkdir(parents=True, exist_ok=True)
112
+ print(f"Extracting {archive.name}")
113
+ with zipfile.ZipFile(archive) as zf:
114
+ zf.extractall(extract_root)
115
+
116
+ signal_dir = next(extract_root.rglob("signal_extractions"), None)
117
+ source = signal_dir if signal_dir is not None else extract_root
118
+ for folder in ("ambient", "attacks"):
119
+ matches = [path for path in source.rglob(folder) if path.is_dir()]
120
+ if not matches:
121
+ raise DownloadError(
122
+ f"ROAD archive does not contain an '{folder}' directory. "
123
+ f"Inspect {extract_root} and place the CSVs manually."
124
+ )
125
+ target = destination / folder
126
+ if target.exists():
127
+ shutil.rmtree(target)
128
+ shutil.move(str(matches[0]), str(target))
129
+
130
+ # Original release includes non-ambient files inside ambient/.
131
+ for extra in (destination / "ambient").iterdir():
132
+ if extra.is_file() and not extra.name.startswith("ambient_"):
133
+ extra.unlink()
134
+
135
+ archive.unlink(missing_ok=True)
136
+ shutil.rmtree(extract_root, ignore_errors=True)
137
+ print(f"ROAD downloaded to {destination}")
138
+
139
+
140
+ def ensure_downloaded(spec: DatasetSpec, root: str | Path) -> Path:
141
+ """Download ``spec`` under ``root/<name>`` and return that directory."""
142
+ destination = dataset_root(root, spec.name)
143
+ if spec.status == "planned":
144
+ raise DownloadError(
145
+ f"{spec.name} is registered but not implemented yet. {spec.notes}"
146
+ )
147
+ if not spec.downloadable:
148
+ if raw_csvs_present(destination / "ambient") or raw_csvs_present(destination / "attacks"):
149
+ return destination
150
+ raise DownloadError(
151
+ f"{spec.name} cannot be downloaded automatically. {spec.notes} "
152
+ f"Expected files under {destination}."
153
+ )
154
+ if spec.name == "syncan":
155
+ download_syncan(destination)
156
+ elif spec.name == "road":
157
+ download_road(destination)
158
+ else:
159
+ raise DownloadError(f"No downloader registered for {spec.name}.")
160
+ return destination