acteval 0.1.1__tar.gz → 0.1.2__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.
Files changed (72) hide show
  1. {acteval-0.1.1 → acteval-0.1.2}/PKG-INFO +2 -2
  2. {acteval-0.1.1 → acteval-0.1.2}/README.md +44 -28
  3. acteval-0.1.2/llms.txt +68 -0
  4. {acteval-0.1.1 → acteval-0.1.2}/pyproject.toml +7 -4
  5. acteval-0.1.2/src/acteval/__main__.py +4 -0
  6. acteval-0.1.2/src/acteval/cli.py +206 -0
  7. acteval-0.1.2/tests/test_cli.py +280 -0
  8. {acteval-0.1.1 → acteval-0.1.2}/.github/workflows/benchmark.yml +0 -0
  9. {acteval-0.1.1 → acteval-0.1.2}/.github/workflows/ci.yml +0 -0
  10. {acteval-0.1.1 → acteval-0.1.2}/.github/workflows/publish.yml +0 -0
  11. {acteval-0.1.1 → acteval-0.1.2}/.gitignore +0 -0
  12. {acteval-0.1.1 → acteval-0.1.2}/scripts/generate_blog_plots.py +0 -0
  13. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/__init__.py +0 -0
  14. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_aggregation.py +0 -0
  15. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_compat.py +0 -0
  16. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_filters.py +0 -0
  17. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_jobs.py +0 -0
  18. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_pipeline.py +0 -0
  19. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_report.py +0 -0
  20. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_result_frame.py +0 -0
  21. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/_splits.py +0 -0
  22. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/config.toml +0 -0
  23. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/__init__.py +0 -0
  24. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/frequency.py +0 -0
  25. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/plot.py +0 -0
  26. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/results.py +0 -0
  27. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/times.py +0 -0
  28. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/transitions.py +0 -0
  29. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/describe/utils.py +0 -0
  30. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/distance/__init__.py +0 -0
  31. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/distance/scalar.py +0 -0
  32. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/distance/wasserstein.py +0 -0
  33. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/evaluate.py +0 -0
  34. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/__init__.py +0 -0
  35. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/_discretise.py +0 -0
  36. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/_pid_features.py +0 -0
  37. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/_utils.py +0 -0
  38. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/catalogue.py +0 -0
  39. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/creativity.py +0 -0
  40. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/frequency.py +0 -0
  41. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/participation.py +0 -0
  42. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/structural.py +0 -0
  43. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/times.py +0 -0
  44. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/features/transitions.py +0 -0
  45. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/pairwise.py +0 -0
  46. {acteval-0.1.1 → acteval-0.1.2}/src/acteval/population.py +0 -0
  47. {acteval-0.1.1 → acteval-0.1.2}/tests/conftest.py +0 -0
  48. {acteval-0.1.1 → acteval-0.1.2}/tests/test_bench_evaluate.py +0 -0
  49. {acteval-0.1.1 → acteval-0.1.2}/tests/test_bench_pairwise.py +0 -0
  50. {acteval-0.1.1 → acteval-0.1.2}/tests/test_compare.py +0 -0
  51. {acteval-0.1.1 → acteval-0.1.2}/tests/test_creativity.py +0 -0
  52. {acteval-0.1.1 → acteval-0.1.2}/tests/test_describe_features.py +0 -0
  53. {acteval-0.1.1 → acteval-0.1.2}/tests/test_distance_emd.py +0 -0
  54. {acteval-0.1.1 → acteval-0.1.2}/tests/test_distance_scalar.py +0 -0
  55. {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_catalogue.py +0 -0
  56. {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_participation.py +0 -0
  57. {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_structural.py +0 -0
  58. {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_times.py +0 -0
  59. {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_transitions.py +0 -0
  60. {acteval-0.1.1 → acteval-0.1.2}/tests/test_features_utils.py +0 -0
  61. {acteval-0.1.1 → acteval-0.1.2}/tests/test_filter.py +0 -0
  62. {acteval-0.1.1 → acteval-0.1.2}/tests/test_frequency.py +0 -0
  63. {acteval-0.1.1 → acteval-0.1.2}/tests/test_integration.py +0 -0
  64. {acteval-0.1.1 → acteval-0.1.2}/tests/test_jobs.py +0 -0
  65. {acteval-0.1.1 → acteval-0.1.2}/tests/test_missing_features.py +0 -0
  66. {acteval-0.1.1 → acteval-0.1.2}/tests/test_pairwise.py +0 -0
  67. {acteval-0.1.1 → acteval-0.1.2}/tests/test_pid_features.py +0 -0
  68. {acteval-0.1.1 → acteval-0.1.2}/tests/test_polars_compat.py +0 -0
  69. {acteval-0.1.1 → acteval-0.1.2}/tests/test_population.py +0 -0
  70. {acteval-0.1.1 → acteval-0.1.2}/tests/test_report.py +0 -0
  71. {acteval-0.1.1 → acteval-0.1.2}/tests/test_validation.py +0 -0
  72. {acteval-0.1.1 → acteval-0.1.2}/uv.lock +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acteval
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Standalone library for evaluating activity sequences
5
- Requires-Python: >=3.8
5
+ Requires-Python: >=3.10
6
6
  Requires-Dist: matplotlib
7
7
  Requires-Dist: numpy
8
8
  Requires-Dist: pandas
@@ -14,6 +14,36 @@ Or with [uv](https://github.com/astral-sh/uv):
14
14
  uv add acteval
15
15
  ```
16
16
 
17
+ ## CLI
18
+
19
+ `acteval` ships with a command-line interface for comparing models without writing Python.
20
+
21
+ ```bash
22
+ # Compare one model to observed data
23
+ acteval observed.csv --model my_model synthetic.csv
24
+
25
+ # Compare multiple models side-by-side
26
+ acteval observed.csv \
27
+ --model model_a synthetic_a.csv \
28
+ --model model_b synthetic_b.csv
29
+
30
+ # Save full results to CSV files
31
+ acteval observed.csv --model my_model synthetic.csv --output results/
32
+
33
+ # Show group-level detail instead of domain summary
34
+ acteval observed.csv --model my_model synthetic.csv --level groups
35
+
36
+ # Split evaluation by attribute (e.g. gender)
37
+ acteval observed.csv \
38
+ --target-attrs target_attrs.csv --split-on gender \
39
+ --model my_model synthetic.csv --attrs my_model synth_attrs.csv
40
+
41
+ # Use a custom config file
42
+ acteval observed.csv --model my_model synthetic.csv --config custom.toml
43
+ ```
44
+
45
+ Input files can be CSV or Parquet (detected by extension). Run `acteval --help` for the full option list.
46
+
17
47
  ## Quick start
18
48
 
19
49
  ```python
@@ -59,6 +89,9 @@ Data is passed as a pandas DataFrame with one row per activity episode:
59
89
  | `act` | str | Activity label (e.g. `"home"`, `"work"`, `"shop"`) |
60
90
  | `start` | numeric | Start time (any consistent unit, e.g. hours) |
61
91
  | `end` | numeric | End time |
92
+ | `duration` | numeric | Duration (`end - start`); can be omitted when both `start` and `end` are provided |
93
+
94
+ Any two of `start`, `end`, and `duration` are sufficient — the third is derived automatically.
62
95
 
63
96
 
64
97
  ```python
@@ -89,18 +122,7 @@ result = compare(observed, synthetic)
89
122
  result = compare(observed, {"model_a": synthetic_a, "model_b": synthetic_b})
90
123
  ```
91
124
 
92
- `result` is a dict with six DataFrames:
93
-
94
- | key | description |
95
- |-----|-------------|
96
- | `"distances"` | Per-feature distances |
97
- | `"group_distances"` | Distances aggregated by feature group |
98
- | `"domain_distances"` | Distances aggregated by domain (participations, transitions, timing) |
99
- | `"descriptions"` | Per-feature descriptive statistics |
100
- | `"group_descriptions"` | Group-level descriptive statistics |
101
- | `"domain_descriptions"` | Domain-level descriptive statistics |
102
-
103
- Pass `report_stats=True` to include additional statistics in the output.
125
+ `result` is an `EvalResult` object. See [Reading the results](#reading-the-results) for how to access distances and descriptions at feature, group, and domain level.
104
126
 
105
127
  ### `Evaluator`
106
128
 
@@ -115,14 +137,6 @@ result_v1 = evaluator.compare({"v1": synthetic_v1})
115
137
  result_v2 = evaluator.compare({"v2": synthetic_v2})
116
138
  ```
117
139
 
118
- For fine-grained control, compare one model at a time then assemble:
119
-
120
- ```python
121
- evaluator.compare_population("v1", synthetic_v1)
122
- evaluator.compare_population("v2", synthetic_v2)
123
- result = evaluator.report()
124
- ```
125
-
126
140
  ### `pairwise_distances(schedules, specs=None)`
127
141
 
128
142
  Compute a single NxN distance matrix between individual schedules. Useful for clustering, outlier detection, or directly comparing a small batch of schedules.
@@ -190,18 +204,20 @@ print(pop.durations) # durations as numpy array
190
204
 
191
205
  ## Reading the results
192
206
 
193
- ### The six DataFrames
207
+ ### Accessing results
194
208
 
195
- `compare()` returns an `EvalResult` with six DataFrames at three levels of aggregation:
209
+ `compare()` returns an `EvalResult` with distances and descriptions at three levels of aggregation. Each level is accessed via a property that returns a view object with `.distances` and `.descriptions` DataFrames:
196
210
 
197
211
  | Property | Index | Content |
198
212
  |----------|-------|---------|
199
- | `result.distances` | `(domain, feature, segment)` | Per-feature distances — lower is closer to observed |
200
- | `result.descriptions` | `(domain, feature, segment)` | Per-feature descriptive statistics (e.g. average start time) |
201
- | `result.group_distances` | `(domain, feature)` | Distances averaged across segments |
202
- | `result.group_descriptions` | `(domain, feature)` | Descriptions averaged across segments |
203
- | `result.domain_distances` | `(domain,)` | Distances averaged across features — one row per domain |
204
- | `result.domain_descriptions` | `(domain,)` | Descriptions averaged across features |
213
+ | `result.features.combined.distances` | `(domain, feature, segment)` | Per-feature distances — lower is closer to observed |
214
+ | `result.features.combined.descriptions` | `(domain, feature, segment)` | Per-feature descriptive statistics (e.g. average start time) |
215
+ | `result.groups.combined.distances` | `(domain, feature)` | Distances averaged across segments |
216
+ | `result.groups.combined.descriptions` | `(domain, feature)` | Descriptions averaged across segments |
217
+ | `result.domains.combined.distances` | `(domain,)` | Distances averaged across features — one row per domain |
218
+ | `result.domains.combined.descriptions` | `(domain,)` | Descriptions averaged across features |
219
+
220
+ Save all levels to CSV at once with `result.save("output_dir/")`.
205
221
 
206
222
  Distances are in the range **0–1** (lower is better). A distance of `0.0` means the synthetic distribution perfectly matches observed; `1.0` is the maximum penalty.
207
223
 
acteval-0.1.2/llms.txt ADDED
@@ -0,0 +1,68 @@
1
+ # acteval
2
+
3
+ > Python library for evaluating synthetic activity schedules: given daily sequences of who-did-what-and-when, measures how well a synthetic population reproduces observed distributions across frequencies, timing, transitions, participation rates, and novelty. Distances are 0–1 (lower is better).
4
+
5
+ ## Install
6
+
7
+ pip install acteval
8
+
9
+ ## Input format
10
+
11
+ pandas DataFrame, one row per activity episode:
12
+ - pid — person identifier (int or str)
13
+ - act — activity label (str, e.g. "home", "work", "shop")
14
+ - start — episode start time (numeric, any consistent unit)
15
+ - end — episode end time (numeric)
16
+ - duration — episode duration (numeric)
17
+
18
+ ## Key API
19
+
20
+ - compare(observed, synthetic, **kwargs) → EvalResult
21
+ Compare one or more synthetic DataFrames to observed.
22
+ synthetic can be a single DataFrame or {"name": df, ...} dict.
23
+
24
+ - Evaluator(observed) — cache observed features for repeated comparisons.
25
+ evaluator.compare({"v1": df_v1}) re-uses cached observed features.
26
+
27
+ - pairwise_distances(schedules, specs=None) → PairwiseResult
28
+ NxN distance matrix between individual schedules.
29
+ result.matrix (numpy), result.to_dataframe() (labeled).
30
+
31
+ ## EvalResult structure
32
+
33
+ Six DataFrames at three aggregation levels:
34
+ - distances / descriptions — per-feature (index: domain, feature, segment)
35
+ - group_distances / group_descriptions — per feature group (index: domain, feature)
36
+ - domain_distances / domain_descriptions — per domain (index: domain)
37
+
38
+ Convenience: result.summary(), result.rank_models(), result.best_model
39
+
40
+ ## Evaluation domains
41
+
42
+ - participations — who does what and how often; participation rates, joint participation
43
+ - transitions — activity sequence patterns (2-, 3-, 4-gram bigrams)
44
+ - timing — start times and durations (EMD via Wasserstein distance)
45
+ - creativity — novelty and diversity of synthetic schedules vs observed
46
+ - feasibility — structural validity: home-based, no consecutive duplicate activities
47
+
48
+ Missing activity penalty: 1.0 (maximum) for timing; None (compute on available data) for participation/transitions.
49
+
50
+ ## Pairwise distance specs
51
+
52
+ from acteval.pairwise import chamfer_spec, soft_dtw_spec, default_pairwise_specs
53
+ - default_pairwise_specs() — MAE on participation counts, bigram counts, mean durations
54
+ - chamfer_spec(max_len, weight) — Chamfer on EOS-padded (N, L, 2) sequences
55
+ - soft_dtw_spec(max_len, gamma, weight) — Soft-DTW on EOS-padded sequences
56
+
57
+ ## Source layout
58
+
59
+ src/acteval/
60
+ - evaluate.py — public API: compare(), Evaluator, evaluate()
61
+ - _pipeline.py — evaluation engine; builds features → groups → domains output
62
+ - population.py — Population: core data structure, integer-encodes activities/persons
63
+ - _jobs.py — JobSpec dataclass, get_jobs(); reads config.toml
64
+ - pairwise.py — pairwise_distances(), PairwiseResult, spec factories
65
+ - features/ — structural, frequency, times, transitions, participation, creativity
66
+ - distance/ — wasserstein.py (EMD via POT), scalar.py (MAE/MSE/MAPE)
67
+ - describe/ — descriptive stats alongside distance metrics
68
+ - _filters.py — filter_novel(): removes sequences seen in base population
@@ -1,8 +1,8 @@
1
1
  [project]
2
2
  name = "acteval"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Standalone library for evaluating activity sequences"
5
- requires-python = ">=3.8"
5
+ requires-python = ">=3.10"
6
6
  dependencies = [
7
7
  "pandas",
8
8
  "numpy",
@@ -26,6 +26,9 @@ dev-dependencies = [
26
26
  "polars>=1.8.2",
27
27
  ]
28
28
 
29
+ [project.scripts]
30
+ acteval = "acteval.cli:main"
31
+
29
32
  [build-system]
30
33
  requires = ["hatchling"]
31
34
  build-backend = "hatchling.build"
@@ -38,7 +41,7 @@ testpaths = ["tests"]
38
41
  addopts = "--ignore=tests/test_bench_evaluate.py --ignore=tests/test_bench_pairwise.py --benchmark-columns=mean,stddev,rounds --benchmark-sort=name"
39
42
 
40
43
  [tool.ruff]
41
- target-version = "py39"
44
+ target-version = "py310"
42
45
 
43
46
  [tool.ruff.lint]
44
47
  select = ["E", "F", "I", "W"]
@@ -48,4 +51,4 @@ ignore = ["E501"]
48
51
  "tests/test_distance_emd.py" = ["E402"]
49
52
 
50
53
  [tool.black]
51
- target-version = ["py39"]
54
+ target-version = ["py310"]
@@ -0,0 +1,4 @@
1
+ from acteval.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,206 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+
7
+ from acteval._report import print_markdown
8
+ from acteval.evaluate import Evaluator
9
+
10
+ _REQUIRED_SCHEDULE_COLS = {"pid", "act"}
11
+ _TIMING_COLS = {"start", "end", "duration"}
12
+
13
+
14
+ def _load_df(path: str) -> pd.DataFrame:
15
+ p = Path(path)
16
+ if p.suffix in (".parquet", ".pq"):
17
+ try:
18
+ return pd.read_parquet(p)
19
+ except ImportError:
20
+ sys.exit(f"Reading {p} requires pyarrow: pip install pyarrow")
21
+ return pd.read_csv(p)
22
+
23
+
24
+ def _validate_schedule(df: pd.DataFrame, path: str) -> None:
25
+ cols = set(df.columns)
26
+ missing_required = _REQUIRED_SCHEDULE_COLS - cols
27
+ if missing_required:
28
+ sys.exit(f"{path}: missing required columns {sorted(missing_required)}")
29
+ timing_present = _TIMING_COLS & cols
30
+ if len(timing_present) < 2:
31
+ sys.exit(
32
+ f"{path}: at least two of {sorted(_TIMING_COLS)} are required; "
33
+ f"found only {sorted(timing_present)}"
34
+ )
35
+
36
+
37
+ def _derive_timing(df: pd.DataFrame) -> pd.DataFrame:
38
+ """Derive the missing timing column when only two of start/end/duration are present."""
39
+ has_start = "start" in df.columns
40
+ has_end = "end" in df.columns
41
+ has_dur = "duration" in df.columns
42
+ if has_start and has_end and not has_dur:
43
+ df = df.copy()
44
+ df["duration"] = df["end"] - df["start"]
45
+ elif has_start and has_dur and not has_end:
46
+ df = df.copy()
47
+ df["end"] = df["start"] + df["duration"]
48
+ elif has_end and has_dur and not has_start:
49
+ df = df.copy()
50
+ df["start"] = df["end"] - df["duration"]
51
+ return df
52
+
53
+
54
+ def _validate_attrs(df: pd.DataFrame, path: str, split_on: list | None = None) -> None:
55
+ if "pid" not in df.columns:
56
+ sys.exit(f"{path}: attributes file missing required column 'pid'")
57
+ if split_on:
58
+ missing = [c for c in split_on if c not in df.columns]
59
+ if missing:
60
+ sys.exit(f"{path}: missing split-on columns {missing}")
61
+
62
+
63
+ def _build_parser() -> argparse.ArgumentParser:
64
+ p = argparse.ArgumentParser(
65
+ prog="acteval",
66
+ description="Compare synthetic activity schedules against observed data.",
67
+ )
68
+ p.add_argument("target", help="Path to observed schedules (CSV or Parquet)")
69
+ p.add_argument(
70
+ "--model",
71
+ nargs=2,
72
+ metavar=("NAME", "PATH"),
73
+ action="append",
74
+ required=True,
75
+ help="Synthetic model to compare (repeatable)",
76
+ )
77
+ p.add_argument(
78
+ "--attrs",
79
+ nargs=2,
80
+ metavar=("NAME", "PATH"),
81
+ action="append",
82
+ help="Per-model attributes file (repeatable; NAME must match a --model NAME)",
83
+ )
84
+ p.add_argument(
85
+ "--target-attrs", metavar="PATH", help="Attributes for the target population"
86
+ )
87
+ p.add_argument(
88
+ "--split-on",
89
+ nargs="+",
90
+ metavar="COL",
91
+ help="Attribute columns to split evaluation on (requires --target-attrs)",
92
+ )
93
+ p.add_argument("--config", metavar="PATH", help="Path to custom config.toml")
94
+ p.add_argument(
95
+ "--level",
96
+ choices=["domains", "groups", "features"],
97
+ default="domains",
98
+ help="Aggregation level to display (default: domains)",
99
+ )
100
+ p.add_argument("--output", metavar="DIR", help="Directory to save CSV results")
101
+ p.add_argument(
102
+ "--verbose", action="store_true", help="Print all aggregation levels"
103
+ )
104
+ return p
105
+
106
+
107
+ def _run(args: argparse.Namespace) -> None:
108
+ # --- load target ---
109
+ target = _load_df(args.target)
110
+ _validate_schedule(target, args.target)
111
+ target = _derive_timing(target)
112
+
113
+ # --- load synthetic models ---
114
+ synthetic: dict[str, pd.DataFrame] = {}
115
+ for name, path in args.model:
116
+ if name in synthetic:
117
+ sys.exit(f"Duplicate --model name '{name}'")
118
+ df = _load_df(path)
119
+ _validate_schedule(df, path)
120
+ synthetic[name] = _derive_timing(df)
121
+
122
+ # --- load per-model attributes ---
123
+ attributes: dict[str, pd.DataFrame] | None = None
124
+ if args.attrs:
125
+ attributes = {}
126
+ for name, path in args.attrs:
127
+ if name not in synthetic:
128
+ sys.exit(f"--attrs NAME '{name}' does not match any --model NAME")
129
+ if name in attributes:
130
+ sys.exit(f"Duplicate --attrs name '{name}'")
131
+ df = _load_df(path)
132
+ if args.split_on:
133
+ _validate_attrs(df, path, args.split_on)
134
+ else:
135
+ _validate_attrs(df, path)
136
+ attributes[name] = df
137
+
138
+ # --- validate split-on consistency ---
139
+ if bool(args.split_on) != bool(args.target_attrs):
140
+ sys.exit("--split-on and --target-attrs must be supplied together")
141
+
142
+ if args.split_on:
143
+ missing_attrs = [n for n in synthetic if n not in (attributes or {})]
144
+ if missing_attrs:
145
+ sys.exit(
146
+ f"--split-on requires --attrs for every model; missing: {missing_attrs}"
147
+ )
148
+
149
+ # --- load target attributes ---
150
+ target_attributes: pd.DataFrame | None = None
151
+ if args.target_attrs:
152
+ target_attributes = _load_df(args.target_attrs)
153
+ _validate_attrs(target_attributes, args.target_attrs, args.split_on)
154
+
155
+ # --- evaluate ---
156
+ print(f"acteval — comparing {len(synthetic)} model(s) to {args.target}\n")
157
+ evaluator = Evaluator(
158
+ target,
159
+ target_attributes=target_attributes,
160
+ split_on=args.split_on,
161
+ config_path=args.config,
162
+ )
163
+ result = evaluator.compare(synthetic, attributes=attributes, verbose=args.verbose)
164
+
165
+ # --- print results ---
166
+ levels_to_print = {"domains"}
167
+ if args.verbose:
168
+ levels_to_print = {"features", "groups", "domains"}
169
+ elif args.level != "domains":
170
+ levels_to_print = {args.level, "domains"}
171
+
172
+ if "features" in levels_to_print:
173
+ print("Feature distances:")
174
+ print_markdown(result.features.combined.distances)
175
+ if result.has_splits:
176
+ print("\nFeature distances by attribute:")
177
+ print_markdown(result.features.by_attribute.distances)
178
+
179
+ if "groups" in levels_to_print:
180
+ print("\nGroup distances:")
181
+ print_markdown(result.groups.combined.distances)
182
+ if result.has_splits:
183
+ print("\nGroup distances by attribute:")
184
+ print_markdown(result.groups.by_attribute.distances)
185
+
186
+ if "domains" in levels_to_print:
187
+ print("\nDomain distances (lower is better):")
188
+ print_markdown(result.domains.combined.distances)
189
+ if result.has_splits:
190
+ print("\nDomain distances by attribute:")
191
+ print_markdown(result.domains.by_attribute.distances)
192
+
193
+ ranked = result.rank_models()
194
+ print(f"\nMean distance: {dict(ranked)}")
195
+ print(f"Best model: {result.best_model}")
196
+
197
+ # --- save ---
198
+ if args.output:
199
+ result.save(args.output)
200
+ print(f"\nResults saved to {args.output}")
201
+
202
+
203
+ def main() -> None:
204
+ parser = _build_parser()
205
+ args = parser.parse_args()
206
+ _run(args)
@@ -0,0 +1,280 @@
1
+ """Tests for the acteval CLI (_build_parser and _run)."""
2
+
3
+ import argparse
4
+ import os
5
+
6
+ import pandas as pd
7
+ import pytest
8
+
9
+ from acteval.cli import _build_parser, _run, _validate_schedule
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Fixtures
13
+ # ---------------------------------------------------------------------------
14
+
15
+ _OBSERVED_ROWS = [
16
+ {"pid": 0, "act": "home", "start": 0, "end": 8, "duration": 8},
17
+ {"pid": 0, "act": "work", "start": 8, "end": 16, "duration": 8},
18
+ {"pid": 0, "act": "home", "start": 16, "end": 24, "duration": 8},
19
+ {"pid": 1, "act": "home", "start": 0, "end": 10, "duration": 10},
20
+ {"pid": 1, "act": "work", "start": 10, "end": 20, "duration": 10},
21
+ {"pid": 1, "act": "home", "start": 20, "end": 24, "duration": 4},
22
+ ]
23
+
24
+ _SYNTHETIC_ROWS = [
25
+ {"pid": 0, "act": "home", "start": 0, "end": 9, "duration": 9},
26
+ {"pid": 0, "act": "work", "start": 9, "end": 17, "duration": 8},
27
+ {"pid": 0, "act": "home", "start": 17, "end": 24, "duration": 7},
28
+ {"pid": 1, "act": "home", "start": 0, "end": 8, "duration": 8},
29
+ {"pid": 1, "act": "work", "start": 8, "end": 16, "duration": 8},
30
+ {"pid": 1, "act": "home", "start": 16, "end": 24, "duration": 8},
31
+ ]
32
+
33
+
34
+ @pytest.fixture
35
+ def csv_files(tmp_path):
36
+ """Write observed and synthetic schedule CSVs; return their paths."""
37
+ obs_path = tmp_path / "observed.csv"
38
+ syn_path = tmp_path / "synthetic.csv"
39
+ pd.DataFrame(_OBSERVED_ROWS).to_csv(obs_path, index=False)
40
+ pd.DataFrame(_SYNTHETIC_ROWS).to_csv(syn_path, index=False)
41
+ return str(obs_path), str(syn_path)
42
+
43
+
44
+ @pytest.fixture
45
+ def attrs_csv(tmp_path):
46
+ """Write a simple attrs CSV and return its path."""
47
+ attrs = pd.DataFrame({"pid": [0, 1], "gender": ["m", "f"]})
48
+ p = tmp_path / "attrs.csv"
49
+ attrs.to_csv(p, index=False)
50
+ return str(p)
51
+
52
+
53
+ def _make_args(target, model_pairs, **kwargs):
54
+ """Build a minimal argparse.Namespace for _run."""
55
+ defaults = dict(
56
+ target=target,
57
+ model=list(model_pairs),
58
+ attrs=None,
59
+ target_attrs=None,
60
+ split_on=None,
61
+ config=None,
62
+ level="domains",
63
+ output=None,
64
+ verbose=False,
65
+ )
66
+ defaults.update(kwargs)
67
+ return argparse.Namespace(**defaults)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Parser tests
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def test_build_parser_returns_parser():
76
+ p = _build_parser()
77
+ assert isinstance(p, argparse.ArgumentParser)
78
+ assert p.prog == "acteval"
79
+
80
+
81
+ def test_parser_requires_target_and_model():
82
+ p = _build_parser()
83
+ with pytest.raises(SystemExit):
84
+ p.parse_args([])
85
+
86
+
87
+ def test_parser_minimal():
88
+ p = _build_parser()
89
+ args = p.parse_args(["obs.csv", "--model", "m1", "syn.csv"])
90
+ assert args.target == "obs.csv"
91
+ assert args.model == [["m1", "syn.csv"]]
92
+ assert args.level == "domains"
93
+ assert args.verbose is False
94
+
95
+
96
+ def test_parser_multiple_models():
97
+ p = _build_parser()
98
+ args = p.parse_args(["obs.csv", "--model", "a", "a.csv", "--model", "b", "b.csv"])
99
+ assert args.model == [["a", "a.csv"], ["b", "b.csv"]]
100
+
101
+
102
+ def test_parser_level_choices():
103
+ p = _build_parser()
104
+ for level in ("domains", "groups", "features"):
105
+ args = p.parse_args(["obs.csv", "--model", "m", "s.csv", "--level", level])
106
+ assert args.level == level
107
+
108
+ with pytest.raises(SystemExit):
109
+ p.parse_args(["obs.csv", "--model", "m", "s.csv", "--level", "invalid"])
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # _validate_schedule tests
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def test_validate_schedule_all_columns_ok():
118
+ df = pd.DataFrame(_OBSERVED_ROWS)
119
+ _validate_schedule(df, "dummy.csv") # should not raise or exit
120
+
121
+
122
+ def test_validate_schedule_start_end_only():
123
+ df = pd.DataFrame([{"pid": 0, "act": "home", "start": 0, "end": 8}])
124
+ _validate_schedule(df, "dummy.csv") # start + end → no error
125
+
126
+
127
+ def test_validate_schedule_start_duration_only():
128
+ df = pd.DataFrame([{"pid": 0, "act": "home", "start": 0, "duration": 8}])
129
+ _validate_schedule(df, "dummy.csv") # start + duration → no error
130
+
131
+
132
+ def test_validate_schedule_end_duration_only():
133
+ df = pd.DataFrame([{"pid": 0, "act": "home", "end": 8, "duration": 8}])
134
+ _validate_schedule(df, "dummy.csv") # end + duration → no error
135
+
136
+
137
+ def test_validate_schedule_missing_pid_exits():
138
+ df = pd.DataFrame([{"act": "home", "start": 0, "end": 8}])
139
+ with pytest.raises(SystemExit) as exc:
140
+ _validate_schedule(df, "bad.csv")
141
+ assert "pid" in str(exc.value.code)
142
+
143
+
144
+ def test_validate_schedule_missing_act_exits():
145
+ df = pd.DataFrame([{"pid": 0, "start": 0, "end": 8}])
146
+ with pytest.raises(SystemExit):
147
+ _validate_schedule(df, "bad.csv")
148
+
149
+
150
+ def test_validate_schedule_only_one_timing_col_exits():
151
+ df = pd.DataFrame([{"pid": 0, "act": "home", "start": 0}])
152
+ with pytest.raises(SystemExit):
153
+ _validate_schedule(df, "bad.csv")
154
+
155
+
156
+ def test_validate_schedule_no_timing_cols_exits():
157
+ df = pd.DataFrame([{"pid": 0, "act": "home"}])
158
+ with pytest.raises(SystemExit):
159
+ _validate_schedule(df, "bad.csv")
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # _run — success paths
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def test_run_basic(csv_files, capsys):
168
+ obs_path, syn_path = csv_files
169
+ args = _make_args(obs_path, [["m1", syn_path]])
170
+ _run(args)
171
+ out = capsys.readouterr().out
172
+ assert "Domain distances" in out
173
+ assert "Best model" in out
174
+
175
+
176
+ def test_run_two_models(csv_files, capsys):
177
+ obs_path, syn_path = csv_files
178
+ args = _make_args(obs_path, [["m1", syn_path], ["m2", syn_path]])
179
+ _run(args)
180
+ out = capsys.readouterr().out
181
+ assert "m1" in out
182
+ assert "m2" in out
183
+
184
+
185
+ def test_run_start_end_only_input(tmp_path, capsys):
186
+ """_run should work with schedules that only have start + end (no duration)."""
187
+ rows = [
188
+ {"pid": 0, "act": "home", "start": 0, "end": 8},
189
+ {"pid": 0, "act": "work", "start": 8, "end": 16},
190
+ {"pid": 1, "act": "home", "start": 0, "end": 12},
191
+ {"pid": 1, "act": "work", "start": 12, "end": 24},
192
+ ]
193
+ p = tmp_path / "sched.csv"
194
+ pd.DataFrame(rows).to_csv(p, index=False)
195
+ args = _make_args(str(p), [["m1", str(p)]])
196
+ _run(args)
197
+ out = capsys.readouterr().out
198
+ assert "Domain distances" in out
199
+
200
+
201
+ def test_run_saves_output(csv_files, tmp_path, capsys):
202
+ obs_path, syn_path = csv_files
203
+ out_dir = str(tmp_path / "results")
204
+ args = _make_args(obs_path, [["m1", syn_path]], output=out_dir)
205
+ _run(args)
206
+ assert os.path.isdir(out_dir)
207
+ out = capsys.readouterr().out
208
+ assert "Results saved" in out
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # _run — validation/error paths
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def test_run_duplicate_model_name_exits(csv_files):
217
+ obs_path, syn_path = csv_files
218
+ args = _make_args(obs_path, [["m1", syn_path], ["m1", syn_path]])
219
+ with pytest.raises(SystemExit):
220
+ _run(args)
221
+
222
+
223
+ def test_run_attrs_name_not_in_model_exits(csv_files, attrs_csv):
224
+ obs_path, syn_path = csv_files
225
+ args = _make_args(obs_path, [["m1", syn_path]])
226
+ args.attrs = [["unknown", attrs_csv]]
227
+ with pytest.raises(SystemExit):
228
+ _run(args)
229
+
230
+
231
+ def test_run_duplicate_attrs_name_exits(csv_files, attrs_csv):
232
+ obs_path, syn_path = csv_files
233
+ args = _make_args(obs_path, [["m1", syn_path]])
234
+ args.attrs = [["m1", attrs_csv], ["m1", attrs_csv]]
235
+ with pytest.raises(SystemExit):
236
+ _run(args)
237
+
238
+
239
+ def test_run_split_on_without_target_attrs_exits(csv_files):
240
+ obs_path, syn_path = csv_files
241
+ args = _make_args(obs_path, [["m1", syn_path]])
242
+ args.split_on = ["gender"]
243
+ with pytest.raises(SystemExit):
244
+ _run(args)
245
+
246
+
247
+ def test_run_target_attrs_without_split_on_exits(csv_files, attrs_csv):
248
+ obs_path, syn_path = csv_files
249
+ args = _make_args(obs_path, [["m1", syn_path]])
250
+ args.target_attrs = attrs_csv
251
+ with pytest.raises(SystemExit):
252
+ _run(args)
253
+
254
+
255
+ def test_run_split_on_missing_model_attrs_exits(csv_files, attrs_csv):
256
+ obs_path, syn_path = csv_files
257
+ args = _make_args(obs_path, [["m1", syn_path]])
258
+ args.target_attrs = attrs_csv
259
+ args.split_on = ["gender"]
260
+ # No per-model attrs supplied — should exit
261
+ with pytest.raises(SystemExit):
262
+ _run(args)
263
+
264
+
265
+ def test_run_target_schedule_missing_pid_exits(tmp_path, csv_files):
266
+ _, syn_path = csv_files
267
+ bad = tmp_path / "bad.csv"
268
+ pd.DataFrame([{"act": "home", "start": 0, "end": 8}]).to_csv(bad, index=False)
269
+ args = _make_args(str(bad), [["m1", syn_path]])
270
+ with pytest.raises(SystemExit):
271
+ _run(args)
272
+
273
+
274
+ def test_run_model_schedule_only_one_timing_col_exits(tmp_path, csv_files):
275
+ obs_path, _ = csv_files
276
+ bad = tmp_path / "bad.csv"
277
+ pd.DataFrame([{"pid": 0, "act": "home", "start": 0}]).to_csv(bad, index=False)
278
+ args = _make_args(obs_path, [["m1", str(bad)]])
279
+ with pytest.raises(SystemExit):
280
+ _run(args)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes