moveq-cli 0.1.2__tar.gz → 0.2.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.
- {moveq_cli-0.1.2/src/moveq_cli.egg-info → moveq_cli-0.2.0}/PKG-INFO +12 -7
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/README.md +11 -6
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/pyproject.toml +1 -1
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/src/moveq_cli/__init__.py +1 -1
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/src/moveq_cli/main.py +47 -13
- {moveq_cli-0.1.2 → moveq_cli-0.2.0/src/moveq_cli.egg-info}/PKG-INFO +12 -7
- moveq_cli-0.2.0/tests/test_main.py +405 -0
- moveq_cli-0.1.2/tests/test_main.py +0 -153
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/LICENSE +0 -0
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/setup.cfg +0 -0
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/src/moveq_cli/__main__.py +0 -0
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/src/moveq_cli.egg-info/SOURCES.txt +0 -0
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/src/moveq_cli.egg-info/dependency_links.txt +0 -0
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/src/moveq_cli.egg-info/entry_points.txt +0 -0
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/src/moveq_cli.egg-info/requires.txt +0 -0
- {moveq_cli-0.1.2 → moveq_cli-0.2.0}/src/moveq_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moveq-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Command-line tool for moveq: Gini, Palma, Concentration Index, composite scores, and catalogue validation from CSV or JSON.
|
|
5
5
|
Author-email: Soura <martisoura@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -54,7 +54,7 @@ only the argparse front-end.
|
|
|
54
54
|
|
|
55
55
|
**`moveq gini`** and **`moveq palma`** read a CSV and treat one column as
|
|
56
56
|
the service value (trips, departures, coverage) and another as the
|
|
57
|
-
population weight.
|
|
57
|
+
population weight. By default they print a single number:
|
|
58
58
|
|
|
59
59
|
- Gini — overall inequality of that service across people (`0` = equal)
|
|
60
60
|
- Palma — mean service of the best-served 10% of population over the
|
|
@@ -62,10 +62,11 @@ population weight. They print a single number:
|
|
|
62
62
|
equal, including all-zero service; `inf` if the bottom 40% have no
|
|
63
63
|
service and the top 10% do not)
|
|
64
64
|
|
|
65
|
-
**`moveq ci`** is the Wagstaff Concentration Index. It needs a
|
|
66
|
-
column
|
|
67
|
-
Positive values
|
|
68
|
-
negative values
|
|
65
|
+
**`moveq ci`** is the Wagstaff Concentration Index. It needs a rank
|
|
66
|
+
column and **`--rank-direction`** (`higher-is-advantaged` or
|
|
67
|
+
`higher-is-disadvantaged`). Positive values are advantage-concentrated;
|
|
68
|
+
negative values are disadvantage-concentrated. A zero or cancelled mean
|
|
69
|
+
exits with code `3`.
|
|
69
70
|
|
|
70
71
|
**`moveq score`** builds a 0–100 weighted composite. Terms live in
|
|
71
72
|
`[0, 1]`; JSON `null` means “this term was not collected.” Missing terms
|
|
@@ -77,6 +78,10 @@ in a JSON file (`--json`).
|
|
|
77
78
|
the same / replace / omit rules: every base section must have an
|
|
78
79
|
explicit decision, replacements need a title, omissions need a note.
|
|
79
80
|
|
|
81
|
+
`moveq gini`, `moveq palma`, and `moveq ci` accept `--json` to print the
|
|
82
|
+
full `EquityResult` (`metric`, `value`, `method`, `n_areas`, warnings).
|
|
83
|
+
The default line is unchanged.
|
|
84
|
+
|
|
80
85
|
Column names are whatever your CSV actually uses; you point at them with
|
|
81
86
|
`--value`, `--weight`, and `--rank`.
|
|
82
87
|
|
|
@@ -95,7 +100,7 @@ Inequality from a CSV (headers `trips`, `population`, `deprivation_rank`):
|
|
|
95
100
|
```bash
|
|
96
101
|
moveq gini data.csv --value trips --weight population
|
|
97
102
|
moveq palma data.csv --value trips --weight population
|
|
98
|
-
moveq ci data.csv --value trips --rank deprivation_rank --weight population
|
|
103
|
+
moveq ci data.csv --value trips --rank deprivation_rank --weight population --rank-direction higher-is-advantaged
|
|
99
104
|
```
|
|
100
105
|
|
|
101
106
|
Composite score, inline or from a file:
|
|
@@ -19,7 +19,7 @@ only the argparse front-end.
|
|
|
19
19
|
|
|
20
20
|
**`moveq gini`** and **`moveq palma`** read a CSV and treat one column as
|
|
21
21
|
the service value (trips, departures, coverage) and another as the
|
|
22
|
-
population weight.
|
|
22
|
+
population weight. By default they print a single number:
|
|
23
23
|
|
|
24
24
|
- Gini — overall inequality of that service across people (`0` = equal)
|
|
25
25
|
- Palma — mean service of the best-served 10% of population over the
|
|
@@ -27,10 +27,11 @@ population weight. They print a single number:
|
|
|
27
27
|
equal, including all-zero service; `inf` if the bottom 40% have no
|
|
28
28
|
service and the top 10% do not)
|
|
29
29
|
|
|
30
|
-
**`moveq ci`** is the Wagstaff Concentration Index. It needs a
|
|
31
|
-
column
|
|
32
|
-
Positive values
|
|
33
|
-
negative values
|
|
30
|
+
**`moveq ci`** is the Wagstaff Concentration Index. It needs a rank
|
|
31
|
+
column and **`--rank-direction`** (`higher-is-advantaged` or
|
|
32
|
+
`higher-is-disadvantaged`). Positive values are advantage-concentrated;
|
|
33
|
+
negative values are disadvantage-concentrated. A zero or cancelled mean
|
|
34
|
+
exits with code `3`.
|
|
34
35
|
|
|
35
36
|
**`moveq score`** builds a 0–100 weighted composite. Terms live in
|
|
36
37
|
`[0, 1]`; JSON `null` means “this term was not collected.” Missing terms
|
|
@@ -42,6 +43,10 @@ in a JSON file (`--json`).
|
|
|
42
43
|
the same / replace / omit rules: every base section must have an
|
|
43
44
|
explicit decision, replacements need a title, omissions need a note.
|
|
44
45
|
|
|
46
|
+
`moveq gini`, `moveq palma`, and `moveq ci` accept `--json` to print the
|
|
47
|
+
full `EquityResult` (`metric`, `value`, `method`, `n_areas`, warnings).
|
|
48
|
+
The default line is unchanged.
|
|
49
|
+
|
|
45
50
|
Column names are whatever your CSV actually uses; you point at them with
|
|
46
51
|
`--value`, `--weight`, and `--rank`.
|
|
47
52
|
|
|
@@ -60,7 +65,7 @@ Inequality from a CSV (headers `trips`, `population`, `deprivation_rank`):
|
|
|
60
65
|
```bash
|
|
61
66
|
moveq gini data.csv --value trips --weight population
|
|
62
67
|
moveq palma data.csv --value trips --weight population
|
|
63
|
-
moveq ci data.csv --value trips --rank deprivation_rank --weight population
|
|
68
|
+
moveq ci data.csv --value trips --rank deprivation_rank --weight population --rank-direction higher-is-advantaged
|
|
64
69
|
```
|
|
65
70
|
|
|
66
71
|
Composite score, inline or from a file:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "moveq-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Command-line tool for moveq: Gini, Palma, Concentration Index, composite scores, and catalogue validation from CSV or JSON."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "BSD-3-Clause"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
moveq gini data.csv --value trips --weight population
|
|
4
4
|
moveq palma data.csv --value trips --weight population
|
|
5
|
-
moveq ci data.csv --value trips --rank deprivation_rank --weight population
|
|
5
|
+
moveq ci data.csv --value trips --rank deprivation_rank --weight population --rank-direction higher-is-advantaged
|
|
6
6
|
moveq score --terms '{"coverage": 0.8, "evening": 0.5}' --weights '{"coverage": 0.6, "evening": 0.4}'
|
|
7
7
|
moveq score config.json
|
|
8
8
|
moveq catalogue validate config.json
|
|
@@ -21,10 +21,11 @@ import numpy as np
|
|
|
21
21
|
|
|
22
22
|
from moveq_catalogue import Catalogue, SectionAction
|
|
23
23
|
from moveq_core import (
|
|
24
|
-
|
|
25
|
-
compute_gini,
|
|
26
|
-
compute_palma_ratio,
|
|
24
|
+
EquityResult,
|
|
27
25
|
compute_score,
|
|
26
|
+
concentration_index_result,
|
|
27
|
+
gini_result,
|
|
28
|
+
palma_result,
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
|
|
@@ -40,25 +41,49 @@ def _read_columns(path: str, columns: list[str]) -> dict[str, np.ndarray]:
|
|
|
40
41
|
return {c: np.array([float(r[c]) for r in rows]) for c in columns}
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
_RANK_DIRECTION_CLI = {
|
|
45
|
+
"higher-is-advantaged": "higher_is_advantaged",
|
|
46
|
+
"higher-is-disadvantaged": "higher_is_disadvantaged",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _print_equity(result: EquityResult, label: str, as_json: bool) -> int:
|
|
51
|
+
if as_json:
|
|
52
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
53
|
+
elif result.status == "undefined" or result.value is None:
|
|
54
|
+
print(f"{label}: undefined", file=sys.stderr)
|
|
55
|
+
if result.reason:
|
|
56
|
+
print(f"reason: {result.reason}", file=sys.stderr)
|
|
57
|
+
else:
|
|
58
|
+
print(f"{label}: {result.value:.4f}")
|
|
59
|
+
if result.status == "undefined":
|
|
60
|
+
return 3
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
43
64
|
def _cmd_gini(args: argparse.Namespace) -> int:
|
|
44
65
|
cols = _read_columns(args.csv, [args.value, args.weight])
|
|
45
|
-
|
|
46
|
-
print(f"gini: {result:.4f}")
|
|
47
|
-
return 0
|
|
66
|
+
return _print_equity(gini_result(cols[args.value], cols[args.weight]), "gini", args.json)
|
|
48
67
|
|
|
49
68
|
|
|
50
69
|
def _cmd_palma(args: argparse.Namespace) -> int:
|
|
51
70
|
cols = _read_columns(args.csv, [args.value, args.weight])
|
|
52
|
-
|
|
53
|
-
print(f"palma: {result:.4f}")
|
|
54
|
-
return 0
|
|
71
|
+
return _print_equity(palma_result(cols[args.value], cols[args.weight]), "palma", args.json)
|
|
55
72
|
|
|
56
73
|
|
|
57
74
|
def _cmd_ci(args: argparse.Namespace) -> int:
|
|
58
75
|
cols = _read_columns(args.csv, [args.value, args.rank, args.weight])
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
rank_direction = _RANK_DIRECTION_CLI[args.rank_direction]
|
|
77
|
+
return _print_equity(
|
|
78
|
+
concentration_index_result(
|
|
79
|
+
cols[args.value],
|
|
80
|
+
cols[args.rank],
|
|
81
|
+
cols[args.weight],
|
|
82
|
+
rank_direction=rank_direction,
|
|
83
|
+
),
|
|
84
|
+
"concentration_index",
|
|
85
|
+
args.json,
|
|
86
|
+
)
|
|
62
87
|
|
|
63
88
|
|
|
64
89
|
def _cmd_score(args: argparse.Namespace) -> int:
|
|
@@ -178,6 +203,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
178
203
|
gini.add_argument("csv", help="Path to input CSV file")
|
|
179
204
|
gini.add_argument("--value", required=True, help="Service-level column")
|
|
180
205
|
gini.add_argument("--weight", required=True, help="Population-weight column")
|
|
206
|
+
gini.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
|
181
207
|
gini.set_defaults(func=_cmd_gini)
|
|
182
208
|
|
|
183
209
|
# Palma
|
|
@@ -185,6 +211,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
185
211
|
palma.add_argument("csv", help="Path to input CSV file")
|
|
186
212
|
palma.add_argument("--value", required=True, help="Service-level column")
|
|
187
213
|
palma.add_argument("--weight", required=True, help="Population-weight column")
|
|
214
|
+
palma.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
|
188
215
|
palma.set_defaults(func=_cmd_palma)
|
|
189
216
|
|
|
190
217
|
# Concentration Index
|
|
@@ -193,6 +220,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
193
220
|
ci.add_argument("--value", required=True, help="Service-level column")
|
|
194
221
|
ci.add_argument("--rank", required=True, help="Ranking column (e.g. deprivation rank)")
|
|
195
222
|
ci.add_argument("--weight", required=True, help="Population-weight column")
|
|
223
|
+
ci.add_argument(
|
|
224
|
+
"--rank-direction",
|
|
225
|
+
required=True,
|
|
226
|
+
choices=sorted(_RANK_DIRECTION_CLI),
|
|
227
|
+
help="Which end of --rank is advantaged (required)",
|
|
228
|
+
)
|
|
229
|
+
ci.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
|
196
230
|
ci.set_defaults(func=_cmd_ci)
|
|
197
231
|
|
|
198
232
|
# Score
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moveq-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Command-line tool for moveq: Gini, Palma, Concentration Index, composite scores, and catalogue validation from CSV or JSON.
|
|
5
5
|
Author-email: Soura <martisoura@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -54,7 +54,7 @@ only the argparse front-end.
|
|
|
54
54
|
|
|
55
55
|
**`moveq gini`** and **`moveq palma`** read a CSV and treat one column as
|
|
56
56
|
the service value (trips, departures, coverage) and another as the
|
|
57
|
-
population weight.
|
|
57
|
+
population weight. By default they print a single number:
|
|
58
58
|
|
|
59
59
|
- Gini — overall inequality of that service across people (`0` = equal)
|
|
60
60
|
- Palma — mean service of the best-served 10% of population over the
|
|
@@ -62,10 +62,11 @@ population weight. They print a single number:
|
|
|
62
62
|
equal, including all-zero service; `inf` if the bottom 40% have no
|
|
63
63
|
service and the top 10% do not)
|
|
64
64
|
|
|
65
|
-
**`moveq ci`** is the Wagstaff Concentration Index. It needs a
|
|
66
|
-
column
|
|
67
|
-
Positive values
|
|
68
|
-
negative values
|
|
65
|
+
**`moveq ci`** is the Wagstaff Concentration Index. It needs a rank
|
|
66
|
+
column and **`--rank-direction`** (`higher-is-advantaged` or
|
|
67
|
+
`higher-is-disadvantaged`). Positive values are advantage-concentrated;
|
|
68
|
+
negative values are disadvantage-concentrated. A zero or cancelled mean
|
|
69
|
+
exits with code `3`.
|
|
69
70
|
|
|
70
71
|
**`moveq score`** builds a 0–100 weighted composite. Terms live in
|
|
71
72
|
`[0, 1]`; JSON `null` means “this term was not collected.” Missing terms
|
|
@@ -77,6 +78,10 @@ in a JSON file (`--json`).
|
|
|
77
78
|
the same / replace / omit rules: every base section must have an
|
|
78
79
|
explicit decision, replacements need a title, omissions need a note.
|
|
79
80
|
|
|
81
|
+
`moveq gini`, `moveq palma`, and `moveq ci` accept `--json` to print the
|
|
82
|
+
full `EquityResult` (`metric`, `value`, `method`, `n_areas`, warnings).
|
|
83
|
+
The default line is unchanged.
|
|
84
|
+
|
|
80
85
|
Column names are whatever your CSV actually uses; you point at them with
|
|
81
86
|
`--value`, `--weight`, and `--rank`.
|
|
82
87
|
|
|
@@ -95,7 +100,7 @@ Inequality from a CSV (headers `trips`, `population`, `deprivation_rank`):
|
|
|
95
100
|
```bash
|
|
96
101
|
moveq gini data.csv --value trips --weight population
|
|
97
102
|
moveq palma data.csv --value trips --weight population
|
|
98
|
-
moveq ci data.csv --value trips --rank deprivation_rank --weight population
|
|
103
|
+
moveq ci data.csv --value trips --rank deprivation_rank --weight population --rank-direction higher-is-advantaged
|
|
99
104
|
```
|
|
100
105
|
|
|
101
106
|
Composite score, inline or from a file:
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from moveq_cli.main import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _write_csv(path, rows, fieldnames):
|
|
12
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
13
|
+
writer = csv.DictWriter(fh, fieldnames=fieldnames)
|
|
14
|
+
writer.writeheader()
|
|
15
|
+
writer.writerows(rows)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_gini_command(tmp_path):
|
|
19
|
+
csv_path = tmp_path / "data.csv"
|
|
20
|
+
_write_csv(
|
|
21
|
+
csv_path,
|
|
22
|
+
[{"trips": "10", "population": "100"}, {"trips": "10", "population": "100"}],
|
|
23
|
+
["trips", "population"],
|
|
24
|
+
)
|
|
25
|
+
buf = io.StringIO()
|
|
26
|
+
with redirect_stdout(buf):
|
|
27
|
+
code = main(["gini", str(csv_path), "--value", "trips", "--weight", "population"])
|
|
28
|
+
assert code == 0
|
|
29
|
+
assert buf.getvalue() == "gini: 0.0000\n"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_gini_json_contains_metric_and_value(tmp_path):
|
|
33
|
+
csv_path = tmp_path / "data.csv"
|
|
34
|
+
_write_csv(
|
|
35
|
+
csv_path,
|
|
36
|
+
[{"trips": "10", "population": "100"}, {"trips": "10", "population": "100"}],
|
|
37
|
+
["trips", "population"],
|
|
38
|
+
)
|
|
39
|
+
buf = io.StringIO()
|
|
40
|
+
with redirect_stdout(buf):
|
|
41
|
+
code = main(
|
|
42
|
+
["gini", str(csv_path), "--value", "trips", "--weight", "population", "--json"]
|
|
43
|
+
)
|
|
44
|
+
assert code == 0
|
|
45
|
+
data = json.loads(buf.getvalue())
|
|
46
|
+
assert data["metric"] == "gini"
|
|
47
|
+
assert data["value"] == pytest.approx(0.0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_palma_command(tmp_path):
|
|
51
|
+
csv_path = tmp_path / "data.csv"
|
|
52
|
+
_write_csv(
|
|
53
|
+
csv_path,
|
|
54
|
+
[{"trips": str(i * 10), "population": "100"} for i in range(1, 11)],
|
|
55
|
+
["trips", "population"],
|
|
56
|
+
)
|
|
57
|
+
buf = io.StringIO()
|
|
58
|
+
with redirect_stdout(buf):
|
|
59
|
+
code = main(["palma", str(csv_path), "--value", "trips", "--weight", "population"])
|
|
60
|
+
assert code == 0
|
|
61
|
+
assert "palma:" in buf.getvalue()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_ci_command(tmp_path):
|
|
65
|
+
csv_path = tmp_path / "data.csv"
|
|
66
|
+
_write_csv(
|
|
67
|
+
csv_path,
|
|
68
|
+
[
|
|
69
|
+
{"trips": "10", "dep_rank": "1", "population": "100"},
|
|
70
|
+
{"trips": "20", "dep_rank": "2", "population": "100"},
|
|
71
|
+
],
|
|
72
|
+
["trips", "dep_rank", "population"],
|
|
73
|
+
)
|
|
74
|
+
buf = io.StringIO()
|
|
75
|
+
with redirect_stdout(buf):
|
|
76
|
+
code = main(
|
|
77
|
+
[
|
|
78
|
+
"ci",
|
|
79
|
+
str(csv_path),
|
|
80
|
+
"--value",
|
|
81
|
+
"trips",
|
|
82
|
+
"--rank",
|
|
83
|
+
"dep_rank",
|
|
84
|
+
"--weight",
|
|
85
|
+
"population",
|
|
86
|
+
"--rank-direction",
|
|
87
|
+
"higher-is-advantaged",
|
|
88
|
+
]
|
|
89
|
+
)
|
|
90
|
+
assert code == 0
|
|
91
|
+
out = buf.getvalue()
|
|
92
|
+
assert "concentration_index:" in out
|
|
93
|
+
assert "{" not in out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_palma_json_contains_metric_and_value(tmp_path):
|
|
97
|
+
csv_path = tmp_path / "data.csv"
|
|
98
|
+
_write_csv(
|
|
99
|
+
csv_path,
|
|
100
|
+
[{"trips": str(i * 10), "population": "100"} for i in range(1, 11)],
|
|
101
|
+
["trips", "population"],
|
|
102
|
+
)
|
|
103
|
+
buf = io.StringIO()
|
|
104
|
+
with redirect_stdout(buf):
|
|
105
|
+
code = main(
|
|
106
|
+
["palma", str(csv_path), "--value", "trips", "--weight", "population", "--json"]
|
|
107
|
+
)
|
|
108
|
+
assert code == 0
|
|
109
|
+
data = json.loads(buf.getvalue())
|
|
110
|
+
assert data["metric"] == "palma"
|
|
111
|
+
assert isinstance(data["value"], float)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_ci_json_contains_metric_and_value(tmp_path):
|
|
115
|
+
csv_path = tmp_path / "data.csv"
|
|
116
|
+
_write_csv(
|
|
117
|
+
csv_path,
|
|
118
|
+
[
|
|
119
|
+
{"trips": "10", "dep_rank": "1", "population": "100"},
|
|
120
|
+
{"trips": "20", "dep_rank": "2", "population": "100"},
|
|
121
|
+
],
|
|
122
|
+
["trips", "dep_rank", "population"],
|
|
123
|
+
)
|
|
124
|
+
buf = io.StringIO()
|
|
125
|
+
with redirect_stdout(buf):
|
|
126
|
+
code = main(
|
|
127
|
+
[
|
|
128
|
+
"ci",
|
|
129
|
+
str(csv_path),
|
|
130
|
+
"--value",
|
|
131
|
+
"trips",
|
|
132
|
+
"--rank",
|
|
133
|
+
"dep_rank",
|
|
134
|
+
"--weight",
|
|
135
|
+
"population",
|
|
136
|
+
"--rank-direction",
|
|
137
|
+
"higher-is-advantaged",
|
|
138
|
+
"--json",
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
assert code == 0
|
|
142
|
+
data = json.loads(buf.getvalue())
|
|
143
|
+
assert data["metric"] == "ci"
|
|
144
|
+
assert isinstance(data["value"], float)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_missing_column_returns_error(tmp_path):
|
|
148
|
+
csv_path = tmp_path / "data.csv"
|
|
149
|
+
_write_csv(csv_path, [{"trips": "10"}], ["trips"])
|
|
150
|
+
code = main(["gini", str(csv_path), "--value", "trips", "--weight", "population"])
|
|
151
|
+
assert code == 1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_score_inline_args():
|
|
155
|
+
buf = io.StringIO()
|
|
156
|
+
with redirect_stdout(buf):
|
|
157
|
+
code = main([
|
|
158
|
+
"score",
|
|
159
|
+
"--terms",
|
|
160
|
+
'{"coverage": 0.8, "evening": 0.5, "frequency": null}',
|
|
161
|
+
"--weights",
|
|
162
|
+
'{"coverage": 0.5, "evening": 0.3, "frequency": 0.2}',
|
|
163
|
+
])
|
|
164
|
+
assert code == 0
|
|
165
|
+
out = buf.getvalue()
|
|
166
|
+
assert "score: 68.8" in out
|
|
167
|
+
assert "renormalised" in out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_score_json_config(tmp_path):
|
|
171
|
+
config_path = tmp_path / "score_config.json"
|
|
172
|
+
config_path.write_text(
|
|
173
|
+
json.dumps({
|
|
174
|
+
"terms": {"coverage": 0.8, "evening": 0.5},
|
|
175
|
+
"weights": {"coverage": 0.6, "evening": 0.4},
|
|
176
|
+
}),
|
|
177
|
+
encoding="utf-8",
|
|
178
|
+
)
|
|
179
|
+
buf = io.StringIO()
|
|
180
|
+
with redirect_stdout(buf):
|
|
181
|
+
code = main(["score", str(config_path), "--json"])
|
|
182
|
+
assert code == 0
|
|
183
|
+
data = json.loads(buf.getvalue())
|
|
184
|
+
assert data["score"] == 68.0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_score_missing_arguments():
|
|
188
|
+
code = main(["score"])
|
|
189
|
+
assert code == 1
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_catalogue_validate_valid(tmp_path):
|
|
193
|
+
cat_path = tmp_path / "catalogue.json"
|
|
194
|
+
cat_path.write_text(
|
|
195
|
+
json.dumps({
|
|
196
|
+
"country": "france",
|
|
197
|
+
"base_sections": ["s1", "s2", "s3"],
|
|
198
|
+
"mappings": [
|
|
199
|
+
{"section_id": "s1", "action": "same"},
|
|
200
|
+
{"section_id": "s2", "action": "replace", "replacement_title": "Local S2"},
|
|
201
|
+
{"section_id": "s3", "action": "omit", "note": "No census data available"},
|
|
202
|
+
],
|
|
203
|
+
}),
|
|
204
|
+
encoding="utf-8",
|
|
205
|
+
)
|
|
206
|
+
buf = io.StringIO()
|
|
207
|
+
with redirect_stdout(buf):
|
|
208
|
+
code = main(["catalogue", "validate", str(cat_path)])
|
|
209
|
+
assert code == 0
|
|
210
|
+
assert "status: valid (fully specified)" in buf.getvalue()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_catalogue_validate_invalid_unmapped(tmp_path):
|
|
214
|
+
cat_path = tmp_path / "catalogue_unmapped.json"
|
|
215
|
+
cat_path.write_text(
|
|
216
|
+
json.dumps({
|
|
217
|
+
"country": "france",
|
|
218
|
+
"base_sections": ["s1", "s2"],
|
|
219
|
+
"mappings": [
|
|
220
|
+
{"section_id": "s1", "action": "same"},
|
|
221
|
+
],
|
|
222
|
+
}),
|
|
223
|
+
encoding="utf-8",
|
|
224
|
+
)
|
|
225
|
+
buf = io.StringIO()
|
|
226
|
+
with redirect_stdout(buf):
|
|
227
|
+
code = main(["catalogue", "validate", str(cat_path), "--json"])
|
|
228
|
+
assert code == 1
|
|
229
|
+
data = json.loads(buf.getvalue())
|
|
230
|
+
assert data["valid"] is False
|
|
231
|
+
assert len(data["issues"]) == 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_ci_missing_rank_direction_exits_nonzero(tmp_path):
|
|
235
|
+
csv_path = tmp_path / "data.csv"
|
|
236
|
+
_write_csv(
|
|
237
|
+
csv_path,
|
|
238
|
+
[
|
|
239
|
+
{"trips": "10", "dep_rank": "1", "population": "100"},
|
|
240
|
+
{"trips": "20", "dep_rank": "2", "population": "100"},
|
|
241
|
+
],
|
|
242
|
+
["trips", "dep_rank", "population"],
|
|
243
|
+
)
|
|
244
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
245
|
+
main(
|
|
246
|
+
[
|
|
247
|
+
"ci",
|
|
248
|
+
str(csv_path),
|
|
249
|
+
"--value",
|
|
250
|
+
"trips",
|
|
251
|
+
"--rank",
|
|
252
|
+
"dep_rank",
|
|
253
|
+
"--weight",
|
|
254
|
+
"population",
|
|
255
|
+
]
|
|
256
|
+
)
|
|
257
|
+
assert excinfo.value.code != 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_ci_invalid_rank_direction_exits_2(tmp_path):
|
|
261
|
+
csv_path = tmp_path / "data.csv"
|
|
262
|
+
_write_csv(
|
|
263
|
+
csv_path,
|
|
264
|
+
[
|
|
265
|
+
{"trips": "10", "dep_rank": "1", "population": "100"},
|
|
266
|
+
{"trips": "20", "dep_rank": "2", "population": "100"},
|
|
267
|
+
],
|
|
268
|
+
["trips", "dep_rank", "population"],
|
|
269
|
+
)
|
|
270
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
271
|
+
main(
|
|
272
|
+
[
|
|
273
|
+
"ci",
|
|
274
|
+
str(csv_path),
|
|
275
|
+
"--value",
|
|
276
|
+
"trips",
|
|
277
|
+
"--rank",
|
|
278
|
+
"dep_rank",
|
|
279
|
+
"--weight",
|
|
280
|
+
"population",
|
|
281
|
+
"--rank-direction",
|
|
282
|
+
"sideways",
|
|
283
|
+
]
|
|
284
|
+
)
|
|
285
|
+
assert excinfo.value.code == 2
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_ci_reversed_rank_with_flipped_direction_matches(tmp_path):
|
|
289
|
+
csv_a = tmp_path / "a.csv"
|
|
290
|
+
csv_b = tmp_path / "b.csv"
|
|
291
|
+
_write_csv(
|
|
292
|
+
csv_a,
|
|
293
|
+
[
|
|
294
|
+
{"trips": "10", "dep_rank": "1", "population": "100"},
|
|
295
|
+
{"trips": "20", "dep_rank": "2", "population": "100"},
|
|
296
|
+
{"trips": "30", "dep_rank": "3", "population": "100"},
|
|
297
|
+
{"trips": "40", "dep_rank": "4", "population": "100"},
|
|
298
|
+
],
|
|
299
|
+
["trips", "dep_rank", "population"],
|
|
300
|
+
)
|
|
301
|
+
_write_csv(
|
|
302
|
+
csv_b,
|
|
303
|
+
[
|
|
304
|
+
{"trips": "10", "dep_rank": "4", "population": "100"},
|
|
305
|
+
{"trips": "20", "dep_rank": "3", "population": "100"},
|
|
306
|
+
{"trips": "30", "dep_rank": "2", "population": "100"},
|
|
307
|
+
{"trips": "40", "dep_rank": "1", "population": "100"},
|
|
308
|
+
],
|
|
309
|
+
["trips", "dep_rank", "population"],
|
|
310
|
+
)
|
|
311
|
+
buf_a = io.StringIO()
|
|
312
|
+
buf_b = io.StringIO()
|
|
313
|
+
with redirect_stdout(buf_a):
|
|
314
|
+
code_a = main(
|
|
315
|
+
[
|
|
316
|
+
"ci",
|
|
317
|
+
str(csv_a),
|
|
318
|
+
"--value",
|
|
319
|
+
"trips",
|
|
320
|
+
"--rank",
|
|
321
|
+
"dep_rank",
|
|
322
|
+
"--weight",
|
|
323
|
+
"population",
|
|
324
|
+
"--rank-direction",
|
|
325
|
+
"higher-is-advantaged",
|
|
326
|
+
]
|
|
327
|
+
)
|
|
328
|
+
with redirect_stdout(buf_b):
|
|
329
|
+
code_b = main(
|
|
330
|
+
[
|
|
331
|
+
"ci",
|
|
332
|
+
str(csv_b),
|
|
333
|
+
"--value",
|
|
334
|
+
"trips",
|
|
335
|
+
"--rank",
|
|
336
|
+
"dep_rank",
|
|
337
|
+
"--weight",
|
|
338
|
+
"population",
|
|
339
|
+
"--rank-direction",
|
|
340
|
+
"higher-is-disadvantaged",
|
|
341
|
+
]
|
|
342
|
+
)
|
|
343
|
+
assert code_a == 0
|
|
344
|
+
assert code_b == 0
|
|
345
|
+
assert buf_a.getvalue() == buf_b.getvalue()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_ci_undefined_exits_3(tmp_path):
|
|
349
|
+
csv_path = tmp_path / "data.csv"
|
|
350
|
+
_write_csv(
|
|
351
|
+
csv_path,
|
|
352
|
+
[
|
|
353
|
+
{"trips": "0", "dep_rank": "1", "population": "100"},
|
|
354
|
+
{"trips": "0", "dep_rank": "2", "population": "100"},
|
|
355
|
+
],
|
|
356
|
+
["trips", "dep_rank", "population"],
|
|
357
|
+
)
|
|
358
|
+
err = io.StringIO()
|
|
359
|
+
with redirect_stderr(err):
|
|
360
|
+
code = main(
|
|
361
|
+
[
|
|
362
|
+
"ci",
|
|
363
|
+
str(csv_path),
|
|
364
|
+
"--value",
|
|
365
|
+
"trips",
|
|
366
|
+
"--rank",
|
|
367
|
+
"dep_rank",
|
|
368
|
+
"--weight",
|
|
369
|
+
"population",
|
|
370
|
+
"--rank-direction",
|
|
371
|
+
"higher-is-advantaged",
|
|
372
|
+
]
|
|
373
|
+
)
|
|
374
|
+
assert code == 3
|
|
375
|
+
assert "undefined" in err.getvalue()
|
|
376
|
+
|
|
377
|
+
buf = io.StringIO()
|
|
378
|
+
with redirect_stdout(buf):
|
|
379
|
+
json_code = main(
|
|
380
|
+
[
|
|
381
|
+
"ci",
|
|
382
|
+
str(csv_path),
|
|
383
|
+
"--value",
|
|
384
|
+
"trips",
|
|
385
|
+
"--rank",
|
|
386
|
+
"dep_rank",
|
|
387
|
+
"--weight",
|
|
388
|
+
"population",
|
|
389
|
+
"--rank-direction",
|
|
390
|
+
"higher-is-advantaged",
|
|
391
|
+
"--json",
|
|
392
|
+
]
|
|
393
|
+
)
|
|
394
|
+
assert json_code == 3
|
|
395
|
+
data = json.loads(buf.getvalue())
|
|
396
|
+
assert data["value"] is None
|
|
397
|
+
assert data["status"] == "undefined"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_help_command():
|
|
401
|
+
try:
|
|
402
|
+
main(["--help"])
|
|
403
|
+
except SystemExit as exc:
|
|
404
|
+
assert exc.code == 0
|
|
405
|
+
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import csv
|
|
2
|
-
import io
|
|
3
|
-
import json
|
|
4
|
-
from contextlib import redirect_stdout
|
|
5
|
-
|
|
6
|
-
from moveq_cli.main import main
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _write_csv(path, rows, fieldnames):
|
|
10
|
-
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
11
|
-
writer = csv.DictWriter(fh, fieldnames=fieldnames)
|
|
12
|
-
writer.writeheader()
|
|
13
|
-
writer.writerows(rows)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_gini_command(tmp_path):
|
|
17
|
-
csv_path = tmp_path / "data.csv"
|
|
18
|
-
_write_csv(
|
|
19
|
-
csv_path,
|
|
20
|
-
[{"trips": "10", "population": "100"}, {"trips": "10", "population": "100"}],
|
|
21
|
-
["trips", "population"],
|
|
22
|
-
)
|
|
23
|
-
buf = io.StringIO()
|
|
24
|
-
with redirect_stdout(buf):
|
|
25
|
-
code = main(["gini", str(csv_path), "--value", "trips", "--weight", "population"])
|
|
26
|
-
assert code == 0
|
|
27
|
-
assert "gini: 0.0000" in buf.getvalue()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_palma_command(tmp_path):
|
|
31
|
-
csv_path = tmp_path / "data.csv"
|
|
32
|
-
_write_csv(
|
|
33
|
-
csv_path,
|
|
34
|
-
[{"trips": str(i * 10), "population": "100"} for i in range(1, 11)],
|
|
35
|
-
["trips", "population"],
|
|
36
|
-
)
|
|
37
|
-
buf = io.StringIO()
|
|
38
|
-
with redirect_stdout(buf):
|
|
39
|
-
code = main(["palma", str(csv_path), "--value", "trips", "--weight", "population"])
|
|
40
|
-
assert code == 0
|
|
41
|
-
assert "palma:" in buf.getvalue()
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_ci_command(tmp_path):
|
|
45
|
-
csv_path = tmp_path / "data.csv"
|
|
46
|
-
_write_csv(
|
|
47
|
-
csv_path,
|
|
48
|
-
[
|
|
49
|
-
{"trips": "10", "dep_rank": "1", "population": "100"},
|
|
50
|
-
{"trips": "20", "dep_rank": "2", "population": "100"},
|
|
51
|
-
],
|
|
52
|
-
["trips", "dep_rank", "population"],
|
|
53
|
-
)
|
|
54
|
-
buf = io.StringIO()
|
|
55
|
-
with redirect_stdout(buf):
|
|
56
|
-
code = main(["ci", str(csv_path), "--value", "trips", "--rank", "dep_rank", "--weight", "population"])
|
|
57
|
-
assert code == 0
|
|
58
|
-
assert "concentration_index:" in buf.getvalue()
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_missing_column_returns_error(tmp_path):
|
|
62
|
-
csv_path = tmp_path / "data.csv"
|
|
63
|
-
_write_csv(csv_path, [{"trips": "10"}], ["trips"])
|
|
64
|
-
code = main(["gini", str(csv_path), "--value", "trips", "--weight", "population"])
|
|
65
|
-
assert code == 1
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_score_inline_args():
|
|
69
|
-
buf = io.StringIO()
|
|
70
|
-
with redirect_stdout(buf):
|
|
71
|
-
code = main([
|
|
72
|
-
"score",
|
|
73
|
-
"--terms",
|
|
74
|
-
'{"coverage": 0.8, "evening": 0.5, "frequency": null}',
|
|
75
|
-
"--weights",
|
|
76
|
-
'{"coverage": 0.5, "evening": 0.3, "frequency": 0.2}',
|
|
77
|
-
])
|
|
78
|
-
assert code == 0
|
|
79
|
-
out = buf.getvalue()
|
|
80
|
-
assert "score: 68.8" in out
|
|
81
|
-
assert "renormalised" in out
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_score_json_config(tmp_path):
|
|
85
|
-
config_path = tmp_path / "score_config.json"
|
|
86
|
-
config_path.write_text(
|
|
87
|
-
json.dumps({
|
|
88
|
-
"terms": {"coverage": 0.8, "evening": 0.5},
|
|
89
|
-
"weights": {"coverage": 0.6, "evening": 0.4},
|
|
90
|
-
}),
|
|
91
|
-
encoding="utf-8",
|
|
92
|
-
)
|
|
93
|
-
buf = io.StringIO()
|
|
94
|
-
with redirect_stdout(buf):
|
|
95
|
-
code = main(["score", str(config_path), "--json"])
|
|
96
|
-
assert code == 0
|
|
97
|
-
data = json.loads(buf.getvalue())
|
|
98
|
-
assert data["score"] == 68.0
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def test_score_missing_arguments():
|
|
102
|
-
code = main(["score"])
|
|
103
|
-
assert code == 1
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def test_catalogue_validate_valid(tmp_path):
|
|
107
|
-
cat_path = tmp_path / "catalogue.json"
|
|
108
|
-
cat_path.write_text(
|
|
109
|
-
json.dumps({
|
|
110
|
-
"country": "france",
|
|
111
|
-
"base_sections": ["s1", "s2", "s3"],
|
|
112
|
-
"mappings": [
|
|
113
|
-
{"section_id": "s1", "action": "same"},
|
|
114
|
-
{"section_id": "s2", "action": "replace", "replacement_title": "Local S2"},
|
|
115
|
-
{"section_id": "s3", "action": "omit", "note": "No census data available"},
|
|
116
|
-
],
|
|
117
|
-
}),
|
|
118
|
-
encoding="utf-8",
|
|
119
|
-
)
|
|
120
|
-
buf = io.StringIO()
|
|
121
|
-
with redirect_stdout(buf):
|
|
122
|
-
code = main(["catalogue", "validate", str(cat_path)])
|
|
123
|
-
assert code == 0
|
|
124
|
-
assert "status: valid (fully specified)" in buf.getvalue()
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def test_catalogue_validate_invalid_unmapped(tmp_path):
|
|
128
|
-
cat_path = tmp_path / "catalogue_unmapped.json"
|
|
129
|
-
cat_path.write_text(
|
|
130
|
-
json.dumps({
|
|
131
|
-
"country": "france",
|
|
132
|
-
"base_sections": ["s1", "s2"],
|
|
133
|
-
"mappings": [
|
|
134
|
-
{"section_id": "s1", "action": "same"},
|
|
135
|
-
],
|
|
136
|
-
}),
|
|
137
|
-
encoding="utf-8",
|
|
138
|
-
)
|
|
139
|
-
buf = io.StringIO()
|
|
140
|
-
with redirect_stdout(buf):
|
|
141
|
-
code = main(["catalogue", "validate", str(cat_path), "--json"])
|
|
142
|
-
assert code == 1
|
|
143
|
-
data = json.loads(buf.getvalue())
|
|
144
|
-
assert data["valid"] is False
|
|
145
|
-
assert len(data["issues"]) == 1
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def test_help_command():
|
|
149
|
-
try:
|
|
150
|
-
main(["--help"])
|
|
151
|
-
except SystemExit as exc:
|
|
152
|
-
assert exc.code == 0
|
|
153
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|