moveq-core 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.
- moveq_core-0.1.0/LICENSE +29 -0
- moveq_core-0.1.0/PKG-INFO +125 -0
- moveq_core-0.1.0/README.md +91 -0
- moveq_core-0.1.0/pyproject.toml +55 -0
- moveq_core-0.1.0/setup.cfg +4 -0
- moveq_core-0.1.0/src/moveq_core/__init__.py +16 -0
- moveq_core-0.1.0/src/moveq_core/equity.py +112 -0
- moveq_core-0.1.0/src/moveq_core/frames.py +54 -0
- moveq_core-0.1.0/src/moveq_core/score.py +148 -0
- moveq_core-0.1.0/src/moveq_core.egg-info/PKG-INFO +125 -0
- moveq_core-0.1.0/src/moveq_core.egg-info/SOURCES.txt +15 -0
- moveq_core-0.1.0/src/moveq_core.egg-info/dependency_links.txt +1 -0
- moveq_core-0.1.0/src/moveq_core.egg-info/requires.txt +7 -0
- moveq_core-0.1.0/src/moveq_core.egg-info/top_level.txt +1 -0
- moveq_core-0.1.0/tests/test_equity.py +63 -0
- moveq_core-0.1.0/tests/test_frames.py +74 -0
- moveq_core-0.1.0/tests/test_score.py +52 -0
moveq_core-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, moveq contributors
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moveq-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core algorithms for transport-equity analysis: Gini, Palma, Concentration Index, and weighted composite scoring.
|
|
5
|
+
Author-email: Soura <martisoura@gmail.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/SVamseekar/moveq
|
|
8
|
+
Project-URL: Documentation, https://github.com/SVamseekar/moveq/blob/main/docs/index.md
|
|
9
|
+
Project-URL: Repository, https://github.com/SVamseekar/moveq
|
|
10
|
+
Project-URL: Issues, https://github.com/SVamseekar/moveq/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/SVamseekar/moveq/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: transport,equity,accessibility,inequality,gini,palma,concentration-index
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: numpy>=1.24
|
|
29
|
+
Provides-Extra: frames
|
|
30
|
+
Requires-Dist: pandas>=2.0; extra == "frames"
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# moveq-core
|
|
36
|
+
|
|
37
|
+
Pure NumPy algorithms for measuring transport equity.
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/moveq-core/)
|
|
40
|
+
[](https://pypi.org/project/moveq-core/)
|
|
41
|
+
[](https://github.com/SVamseekar/moveq/blob/main/LICENSE)
|
|
42
|
+
|
|
43
|
+
- **Population-weighted Gini coefficient** via numerical Lorenz curve integration
|
|
44
|
+
- **Palma ratio** (top 10% highest-service vs bottom 40% lowest-service population)
|
|
45
|
+
- **Wagstaff Concentration Index** via fractional-rank weighted covariance
|
|
46
|
+
- **Weighted composite scoring** with dynamic missing-term renormalization
|
|
47
|
+
|
|
48
|
+
No I/O, no database, no framework lock-in: pass NumPy arrays, get numbers back.
|
|
49
|
+
|
|
50
|
+
Most applications should install the umbrella package instead:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install moveq
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
Requires Python 3.10+.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install moveq-core
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Pandas DataFrame helpers:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install "moveq-core[frames]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Quickstart
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import numpy as np
|
|
74
|
+
from moveq_core import (
|
|
75
|
+
compute_gini,
|
|
76
|
+
compute_palma_ratio,
|
|
77
|
+
compute_concentration_index,
|
|
78
|
+
compute_score,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
service = np.array([10.0, 20.0, 5.0, 50.0, 8.0])
|
|
82
|
+
population = np.array([1000, 800, 1200, 300, 900])
|
|
83
|
+
deprivation_rank = np.array([1, 3, 2, 5, 4]) # 1 = most deprived
|
|
84
|
+
|
|
85
|
+
gini = compute_gini(service, population)
|
|
86
|
+
palma = compute_palma_ratio(service, population)
|
|
87
|
+
ci = compute_concentration_index(service, deprivation_rank, population)
|
|
88
|
+
|
|
89
|
+
score_res = compute_score(
|
|
90
|
+
terms={"coverage": 0.75, "evening": 0.50, "night": None},
|
|
91
|
+
weights={"coverage": 0.50, "evening": 0.30, "night": 0.20},
|
|
92
|
+
)
|
|
93
|
+
print(f"Score: {score_res.score:.1f} ({score_res.note})")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### DataFrame helpers (`moveq_core.frames`)
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import pandas as pd
|
|
100
|
+
from moveq_core.frames import compute_vulnerability_index, identify_multiply_deprived
|
|
101
|
+
|
|
102
|
+
df = pd.DataFrame({
|
|
103
|
+
"unemployment_pct": [5.0, 12.0, 20.0],
|
|
104
|
+
"no_car_pct": [10.0, 30.0, 60.0],
|
|
105
|
+
"elderly_pct": [15.0, 25.0, 35.0],
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
vulnerability = compute_vulnerability_index(
|
|
109
|
+
df, ["unemployment_pct", "no_car_pct", "elderly_pct"]
|
|
110
|
+
)
|
|
111
|
+
flagged = identify_multiply_deprived(
|
|
112
|
+
df, ["unemployment_pct", "no_car_pct", "elderly_pct"], min_factors=2
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Documentation
|
|
117
|
+
|
|
118
|
+
- [Methodology](https://github.com/SVamseekar/moveq/blob/main/docs/methodology.md)
|
|
119
|
+
- [API reference](https://github.com/SVamseekar/moveq/blob/main/docs/api_reference.md)
|
|
120
|
+
- [Source repository](https://github.com/SVamseekar/moveq)
|
|
121
|
+
- [Changelog](https://github.com/SVamseekar/moveq/blob/main/CHANGELOG.md)
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
[BSD 3-Clause](https://github.com/SVamseekar/moveq/blob/main/LICENSE).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# moveq-core
|
|
2
|
+
|
|
3
|
+
Pure NumPy algorithms for measuring transport equity.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/moveq-core/)
|
|
6
|
+
[](https://pypi.org/project/moveq-core/)
|
|
7
|
+
[](https://github.com/SVamseekar/moveq/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
- **Population-weighted Gini coefficient** via numerical Lorenz curve integration
|
|
10
|
+
- **Palma ratio** (top 10% highest-service vs bottom 40% lowest-service population)
|
|
11
|
+
- **Wagstaff Concentration Index** via fractional-rank weighted covariance
|
|
12
|
+
- **Weighted composite scoring** with dynamic missing-term renormalization
|
|
13
|
+
|
|
14
|
+
No I/O, no database, no framework lock-in: pass NumPy arrays, get numbers back.
|
|
15
|
+
|
|
16
|
+
Most applications should install the umbrella package instead:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install moveq
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Requires Python 3.10+.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install moveq-core
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Pandas DataFrame helpers:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install "moveq-core[frames]"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import numpy as np
|
|
40
|
+
from moveq_core import (
|
|
41
|
+
compute_gini,
|
|
42
|
+
compute_palma_ratio,
|
|
43
|
+
compute_concentration_index,
|
|
44
|
+
compute_score,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
service = np.array([10.0, 20.0, 5.0, 50.0, 8.0])
|
|
48
|
+
population = np.array([1000, 800, 1200, 300, 900])
|
|
49
|
+
deprivation_rank = np.array([1, 3, 2, 5, 4]) # 1 = most deprived
|
|
50
|
+
|
|
51
|
+
gini = compute_gini(service, population)
|
|
52
|
+
palma = compute_palma_ratio(service, population)
|
|
53
|
+
ci = compute_concentration_index(service, deprivation_rank, population)
|
|
54
|
+
|
|
55
|
+
score_res = compute_score(
|
|
56
|
+
terms={"coverage": 0.75, "evening": 0.50, "night": None},
|
|
57
|
+
weights={"coverage": 0.50, "evening": 0.30, "night": 0.20},
|
|
58
|
+
)
|
|
59
|
+
print(f"Score: {score_res.score:.1f} ({score_res.note})")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### DataFrame helpers (`moveq_core.frames`)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import pandas as pd
|
|
66
|
+
from moveq_core.frames import compute_vulnerability_index, identify_multiply_deprived
|
|
67
|
+
|
|
68
|
+
df = pd.DataFrame({
|
|
69
|
+
"unemployment_pct": [5.0, 12.0, 20.0],
|
|
70
|
+
"no_car_pct": [10.0, 30.0, 60.0],
|
|
71
|
+
"elderly_pct": [15.0, 25.0, 35.0],
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
vulnerability = compute_vulnerability_index(
|
|
75
|
+
df, ["unemployment_pct", "no_car_pct", "elderly_pct"]
|
|
76
|
+
)
|
|
77
|
+
flagged = identify_multiply_deprived(
|
|
78
|
+
df, ["unemployment_pct", "no_car_pct", "elderly_pct"], min_factors=2
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Documentation
|
|
83
|
+
|
|
84
|
+
- [Methodology](https://github.com/SVamseekar/moveq/blob/main/docs/methodology.md)
|
|
85
|
+
- [API reference](https://github.com/SVamseekar/moveq/blob/main/docs/api_reference.md)
|
|
86
|
+
- [Source repository](https://github.com/SVamseekar/moveq)
|
|
87
|
+
- [Changelog](https://github.com/SVamseekar/moveq/blob/main/CHANGELOG.md)
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
[BSD 3-Clause](https://github.com/SVamseekar/moveq/blob/main/LICENSE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "moveq-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Core algorithms for transport-equity analysis: Gini, Palma, Concentration Index, and weighted composite scoring."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "BSD-3-Clause"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Soura", email = "martisoura@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
keywords = [
|
|
17
|
+
"transport",
|
|
18
|
+
"equity",
|
|
19
|
+
"accessibility",
|
|
20
|
+
"inequality",
|
|
21
|
+
"gini",
|
|
22
|
+
"palma",
|
|
23
|
+
"concentration-index",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Intended Audience :: Science/Research",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Programming Language :: Python",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Topic :: Scientific/Engineering",
|
|
38
|
+
]
|
|
39
|
+
dependencies = [
|
|
40
|
+
"numpy>=1.24",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
frames = ["pandas>=2.0"]
|
|
45
|
+
test = ["pytest>=7"]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/SVamseekar/moveq"
|
|
49
|
+
Documentation = "https://github.com/SVamseekar/moveq/blob/main/docs/index.md"
|
|
50
|
+
Repository = "https://github.com/SVamseekar/moveq"
|
|
51
|
+
Issues = "https://github.com/SVamseekar/moveq/issues"
|
|
52
|
+
Changelog = "https://github.com/SVamseekar/moveq/blob/main/CHANGELOG.md"
|
|
53
|
+
|
|
54
|
+
[tool.setuptools.packages.find]
|
|
55
|
+
where = ["src"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""moveq-core — pure algorithms for transport-equity analysis."""
|
|
2
|
+
|
|
3
|
+
from moveq_core.equity import compute_concentration_index, compute_gini, compute_palma_ratio
|
|
4
|
+
from moveq_core.score import ScoreComponent, ScoreResult, clip01, compute_score
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"compute_gini",
|
|
8
|
+
"compute_palma_ratio",
|
|
9
|
+
"compute_concentration_index",
|
|
10
|
+
"compute_score",
|
|
11
|
+
"clip01",
|
|
12
|
+
"ScoreComponent",
|
|
13
|
+
"ScoreResult",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Population-weighted inequality measures for transport-equity analysis.
|
|
2
|
+
|
|
3
|
+
Three measures, one shared idea: service is a resource, population is the
|
|
4
|
+
weight, and we ask how unevenly the resource is spread across the weight.
|
|
5
|
+
|
|
6
|
+
Gini — overall inequality via the Lorenz curve, in [0, 1]
|
|
7
|
+
Palma ratio — top-10% mean service / bottom-40% mean service
|
|
8
|
+
Concentration Index — inequality correlated with a rank (e.g. deprivation),
|
|
9
|
+
in [-1, 1]; positive = pro-rich, negative = pro-poor
|
|
10
|
+
|
|
11
|
+
NumPy 2.x guard: uses ``trapezoid`` with a fallback to the removed ``trapz``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
_trapezoid = getattr(np, "trapezoid", getattr(np, "trapz", None))
|
|
19
|
+
if _trapezoid is None:
|
|
20
|
+
raise ImportError("NumPy has neither 'trapezoid' nor 'trapz' — unsupported version")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_gini(values: np.ndarray, weights: np.ndarray) -> float:
|
|
24
|
+
"""Population-weighted Gini coefficient via Lorenz curve area.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
values: Service level per areal unit (e.g. trips per neighbourhood).
|
|
28
|
+
weights: Population weight per unit.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Gini coefficient in [0, 1]. 0 = perfect equality, 1 = maximum inequality.
|
|
32
|
+
"""
|
|
33
|
+
values = np.asarray(values, dtype=float)
|
|
34
|
+
weights = np.asarray(weights, dtype=float)
|
|
35
|
+
|
|
36
|
+
order = np.argsort(values)
|
|
37
|
+
values = values[order]
|
|
38
|
+
weights = weights[order]
|
|
39
|
+
|
|
40
|
+
cum_pop = np.cumsum(weights) / weights.sum()
|
|
41
|
+
cum_service = np.cumsum(values * weights) / (values * weights).sum()
|
|
42
|
+
|
|
43
|
+
cum_pop = np.concatenate([[0], cum_pop])
|
|
44
|
+
cum_service = np.concatenate([[0], cum_service])
|
|
45
|
+
|
|
46
|
+
lorenz_area = _trapezoid(cum_service, cum_pop)
|
|
47
|
+
return float(1 - 2 * lorenz_area)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def compute_palma_ratio(values: np.ndarray, weights: np.ndarray) -> float:
|
|
51
|
+
"""Palma ratio: mean service in the top 10% / mean service in the bottom 40%.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
values: Service level per areal unit.
|
|
55
|
+
weights: Population weight per unit.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Palma ratio. Higher = more unequal. ``inf`` if the bottom 40% has zero
|
|
59
|
+
mean service.
|
|
60
|
+
"""
|
|
61
|
+
values = np.asarray(values, dtype=float)
|
|
62
|
+
weights = np.asarray(weights, dtype=float)
|
|
63
|
+
|
|
64
|
+
order = np.argsort(values)
|
|
65
|
+
values = values[order]
|
|
66
|
+
weights = weights[order]
|
|
67
|
+
|
|
68
|
+
cum_pop_frac = np.cumsum(weights) / weights.sum()
|
|
69
|
+
|
|
70
|
+
bottom_mask = cum_pop_frac <= 0.40
|
|
71
|
+
top_mask = cum_pop_frac > 0.90
|
|
72
|
+
|
|
73
|
+
bottom_mean = (
|
|
74
|
+
np.average(values[bottom_mask], weights=weights[bottom_mask]) if bottom_mask.sum() > 0 else 0.0
|
|
75
|
+
)
|
|
76
|
+
top_mean = np.average(values[top_mask], weights=weights[top_mask]) if top_mask.sum() > 0 else 0.0
|
|
77
|
+
|
|
78
|
+
return float(top_mean / bottom_mean) if bottom_mean > 0 else float("inf")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def compute_concentration_index(
|
|
82
|
+
service: np.ndarray, rank: np.ndarray, population: np.ndarray
|
|
83
|
+
) -> float:
|
|
84
|
+
"""Wagstaff Concentration Index (CI) via the covariance method.
|
|
85
|
+
|
|
86
|
+
Positive CI = service concentrated in areas with a higher rank value
|
|
87
|
+
(e.g. less deprived, if rank is a deprivation rank). Negative CI = service
|
|
88
|
+
concentrated in lower-rank (e.g. more deprived) areas.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
service: Service level per areal unit.
|
|
92
|
+
rank: Ranking variable per unit (e.g. deprivation rank; 1 = most
|
|
93
|
+
deprived, higher = less deprived).
|
|
94
|
+
population: Population weight per unit.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Concentration Index in [-1, 1].
|
|
98
|
+
"""
|
|
99
|
+
service = np.asarray(service, dtype=float)
|
|
100
|
+
rank = np.asarray(rank, dtype=float)
|
|
101
|
+
population = np.asarray(population, dtype=float)
|
|
102
|
+
|
|
103
|
+
total_pop = population.sum()
|
|
104
|
+
order = np.argsort(rank)
|
|
105
|
+
pop_sorted = population[order]
|
|
106
|
+
frac_rank = (np.cumsum(pop_sorted) - 0.5 * pop_sorted) / total_pop
|
|
107
|
+
|
|
108
|
+
service_sorted = service[order]
|
|
109
|
+
|
|
110
|
+
mean_service = np.average(service_sorted, weights=pop_sorted)
|
|
111
|
+
cov = np.average((service_sorted - mean_service) * (frac_rank - 0.5), weights=pop_sorted)
|
|
112
|
+
return float(2 * cov / mean_service) if mean_service > 0 else 0.0
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""DataFrame convenience helpers. Requires the ``frames`` extra (pandas).
|
|
2
|
+
|
|
3
|
+
Kept separate from :mod:`moveq_core.equity` so the core numpy-only API has
|
|
4
|
+
no pandas dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import pandas as pd
|
|
11
|
+
except ImportError as exc: # pragma: no cover
|
|
12
|
+
raise ImportError(
|
|
13
|
+
"moveq_core.frames requires pandas — install with `pip install \"moveq-core[frames]\"`"
|
|
14
|
+
) from exc
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_vulnerability_index(df: "pd.DataFrame", factors: list[str]) -> "pd.Series":
|
|
18
|
+
"""Equal-weighted 0-100 vulnerability index across the given factor columns.
|
|
19
|
+
|
|
20
|
+
Each factor is min-max normalised to 0-100 before averaging, so factors
|
|
21
|
+
on different scales (a score, a percentage, a rate) contribute equally.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
df: Frame containing the factor columns.
|
|
25
|
+
factors: Column names to combine (e.g. deprivation score, no-car %,
|
|
26
|
+
elderly %, disability %, unemployment rate).
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Series of vulnerability scores (0-100), rounded to 2 decimals.
|
|
30
|
+
"""
|
|
31
|
+
normalised = pd.DataFrame(index=df.index)
|
|
32
|
+
for col in factors:
|
|
33
|
+
mn, mx = df[col].min(), df[col].max()
|
|
34
|
+
normalised[col] = (df[col] - mn) / (mx - mn) * 100 if mx > mn else 0.0
|
|
35
|
+
return normalised.mean(axis=1).round(2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def identify_multiply_deprived(df: "pd.DataFrame", factors: list[str], min_factors: int = 3) -> "pd.Series":
|
|
39
|
+
"""Flag rows in the worst tertile on at least ``min_factors`` of the given factors.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
df: Frame containing the factor columns.
|
|
43
|
+
factors: Column names where a higher value means more deprived.
|
|
44
|
+
min_factors: Minimum number of factors a row must be in the worst
|
|
45
|
+
tertile on to be flagged.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Boolean Series — True where the row is multiply-deprived.
|
|
49
|
+
"""
|
|
50
|
+
worst_tertile = pd.DataFrame(index=df.index)
|
|
51
|
+
for col in factors:
|
|
52
|
+
threshold = df[col].quantile(2 / 3)
|
|
53
|
+
worst_tertile[col] = df[col] >= threshold
|
|
54
|
+
return worst_tertile.sum(axis=1) >= min_factors
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Configurable weighted composite score (0-100) with graceful missing-term handling.
|
|
2
|
+
|
|
3
|
+
The pattern: define named terms in [0, 1], each with a design weight. Score
|
|
4
|
+
is ``100 * sum(weight * value)``. If a term is missing (``None``), it is
|
|
5
|
+
dropped and the remaining weights are renormalised — the score never
|
|
6
|
+
silently treats a missing input as zero.
|
|
7
|
+
|
|
8
|
+
Unlike a fixed formula, weights and labels are supplied by the caller, so
|
|
9
|
+
the same engine works for any composite indicator (a bus-access score, a
|
|
10
|
+
walkability score, a service-quality score, ...).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clip01(value: float) -> float:
|
|
20
|
+
return max(0.0, min(1.0, float(value)))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ScoreComponent:
|
|
25
|
+
id: str
|
|
26
|
+
label: str
|
|
27
|
+
design_weight: float
|
|
28
|
+
weight_used: float
|
|
29
|
+
value: float | None
|
|
30
|
+
missing: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ScoreResult:
|
|
35
|
+
score: float | None
|
|
36
|
+
components: list[ScoreComponent]
|
|
37
|
+
dropped: list[str]
|
|
38
|
+
n_areas: int | None
|
|
39
|
+
note: str | None
|
|
40
|
+
context: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"score": None if self.score is None else round(self.score, 1),
|
|
45
|
+
"components": [
|
|
46
|
+
{
|
|
47
|
+
"id": c.id,
|
|
48
|
+
"label": c.label,
|
|
49
|
+
"design_weight": c.design_weight,
|
|
50
|
+
"weight_used": c.weight_used,
|
|
51
|
+
"value": None if c.value is None else round(c.value, 4),
|
|
52
|
+
"missing": c.missing,
|
|
53
|
+
}
|
|
54
|
+
for c in self.components
|
|
55
|
+
],
|
|
56
|
+
"dropped": list(self.dropped),
|
|
57
|
+
"n_areas": self.n_areas,
|
|
58
|
+
"note": self.note,
|
|
59
|
+
"context": dict(self.context),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compute_score(
|
|
64
|
+
terms: dict[str, float | None],
|
|
65
|
+
weights: dict[str, float],
|
|
66
|
+
*,
|
|
67
|
+
labels: dict[str, str] | None = None,
|
|
68
|
+
n_areas: int | None = None,
|
|
69
|
+
context: dict[str, str] | None = None,
|
|
70
|
+
empty_note: str = "No score for this cut — required inputs are missing.",
|
|
71
|
+
) -> ScoreResult:
|
|
72
|
+
"""Compute a weighted composite score from 0-1 terms (``None`` = missing).
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
terms: Mapping of term id to a value in [0, 1], or ``None`` if the
|
|
76
|
+
term has no data for this cut.
|
|
77
|
+
weights: Mapping of term id to its design weight. Keys must be a
|
|
78
|
+
superset of ``terms``' keys that matter to the score.
|
|
79
|
+
labels: Optional human-readable label per term id, used in notes.
|
|
80
|
+
n_areas: Optional count of areal units backing this score.
|
|
81
|
+
context: Optional free-form context (e.g. region, filter) carried
|
|
82
|
+
through to the result for traceability.
|
|
83
|
+
empty_note: Note returned when every term is missing.
|
|
84
|
+
"""
|
|
85
|
+
labels = labels or {k: k for k in weights}
|
|
86
|
+
context = context or {}
|
|
87
|
+
|
|
88
|
+
present: list[tuple[str, float, float]] = []
|
|
89
|
+
dropped: list[str] = []
|
|
90
|
+
for key, design_w in weights.items():
|
|
91
|
+
raw = terms.get(key)
|
|
92
|
+
if raw is None:
|
|
93
|
+
dropped.append(key)
|
|
94
|
+
continue
|
|
95
|
+
present.append((key, design_w, clip01(raw)))
|
|
96
|
+
|
|
97
|
+
if not present:
|
|
98
|
+
components = [
|
|
99
|
+
ScoreComponent(
|
|
100
|
+
id=k, label=labels.get(k, k), design_weight=w, weight_used=0.0, value=None, missing=True
|
|
101
|
+
)
|
|
102
|
+
for k, w in weights.items()
|
|
103
|
+
]
|
|
104
|
+
return ScoreResult(
|
|
105
|
+
score=None,
|
|
106
|
+
components=components,
|
|
107
|
+
dropped=list(weights),
|
|
108
|
+
n_areas=n_areas or 0,
|
|
109
|
+
note=empty_note,
|
|
110
|
+
context=context,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
weight_sum = sum(w for _, w, _ in present)
|
|
114
|
+
components = []
|
|
115
|
+
weighted = 0.0
|
|
116
|
+
present_ids = {k for k, _, _ in present}
|
|
117
|
+
for key, design_w in weights.items():
|
|
118
|
+
if key not in present_ids:
|
|
119
|
+
components.append(
|
|
120
|
+
ScoreComponent(
|
|
121
|
+
id=key, label=labels.get(key, key), design_weight=design_w,
|
|
122
|
+
weight_used=0.0, value=None, missing=True,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
value = next(v for k, _, v in present if k == key)
|
|
127
|
+
used = design_w / weight_sum
|
|
128
|
+
weighted += used * value
|
|
129
|
+
components.append(
|
|
130
|
+
ScoreComponent(
|
|
131
|
+
id=key, label=labels.get(key, key), design_weight=design_w,
|
|
132
|
+
weight_used=used, value=value, missing=False,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
note = None
|
|
137
|
+
if dropped:
|
|
138
|
+
dropped_labels = ", ".join(labels.get(k, k).split(" (")[0].lower() for k in dropped)
|
|
139
|
+
note = f"{dropped_labels} not in this cut — weights renormalised."
|
|
140
|
+
|
|
141
|
+
return ScoreResult(
|
|
142
|
+
score=100.0 * weighted,
|
|
143
|
+
components=components,
|
|
144
|
+
dropped=dropped,
|
|
145
|
+
n_areas=n_areas,
|
|
146
|
+
note=note,
|
|
147
|
+
context=context,
|
|
148
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moveq-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core algorithms for transport-equity analysis: Gini, Palma, Concentration Index, and weighted composite scoring.
|
|
5
|
+
Author-email: Soura <martisoura@gmail.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/SVamseekar/moveq
|
|
8
|
+
Project-URL: Documentation, https://github.com/SVamseekar/moveq/blob/main/docs/index.md
|
|
9
|
+
Project-URL: Repository, https://github.com/SVamseekar/moveq
|
|
10
|
+
Project-URL: Issues, https://github.com/SVamseekar/moveq/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/SVamseekar/moveq/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: transport,equity,accessibility,inequality,gini,palma,concentration-index
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: numpy>=1.24
|
|
29
|
+
Provides-Extra: frames
|
|
30
|
+
Requires-Dist: pandas>=2.0; extra == "frames"
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# moveq-core
|
|
36
|
+
|
|
37
|
+
Pure NumPy algorithms for measuring transport equity.
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/moveq-core/)
|
|
40
|
+
[](https://pypi.org/project/moveq-core/)
|
|
41
|
+
[](https://github.com/SVamseekar/moveq/blob/main/LICENSE)
|
|
42
|
+
|
|
43
|
+
- **Population-weighted Gini coefficient** via numerical Lorenz curve integration
|
|
44
|
+
- **Palma ratio** (top 10% highest-service vs bottom 40% lowest-service population)
|
|
45
|
+
- **Wagstaff Concentration Index** via fractional-rank weighted covariance
|
|
46
|
+
- **Weighted composite scoring** with dynamic missing-term renormalization
|
|
47
|
+
|
|
48
|
+
No I/O, no database, no framework lock-in: pass NumPy arrays, get numbers back.
|
|
49
|
+
|
|
50
|
+
Most applications should install the umbrella package instead:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install moveq
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
Requires Python 3.10+.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install moveq-core
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Pandas DataFrame helpers:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install "moveq-core[frames]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Quickstart
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import numpy as np
|
|
74
|
+
from moveq_core import (
|
|
75
|
+
compute_gini,
|
|
76
|
+
compute_palma_ratio,
|
|
77
|
+
compute_concentration_index,
|
|
78
|
+
compute_score,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
service = np.array([10.0, 20.0, 5.0, 50.0, 8.0])
|
|
82
|
+
population = np.array([1000, 800, 1200, 300, 900])
|
|
83
|
+
deprivation_rank = np.array([1, 3, 2, 5, 4]) # 1 = most deprived
|
|
84
|
+
|
|
85
|
+
gini = compute_gini(service, population)
|
|
86
|
+
palma = compute_palma_ratio(service, population)
|
|
87
|
+
ci = compute_concentration_index(service, deprivation_rank, population)
|
|
88
|
+
|
|
89
|
+
score_res = compute_score(
|
|
90
|
+
terms={"coverage": 0.75, "evening": 0.50, "night": None},
|
|
91
|
+
weights={"coverage": 0.50, "evening": 0.30, "night": 0.20},
|
|
92
|
+
)
|
|
93
|
+
print(f"Score: {score_res.score:.1f} ({score_res.note})")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### DataFrame helpers (`moveq_core.frames`)
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import pandas as pd
|
|
100
|
+
from moveq_core.frames import compute_vulnerability_index, identify_multiply_deprived
|
|
101
|
+
|
|
102
|
+
df = pd.DataFrame({
|
|
103
|
+
"unemployment_pct": [5.0, 12.0, 20.0],
|
|
104
|
+
"no_car_pct": [10.0, 30.0, 60.0],
|
|
105
|
+
"elderly_pct": [15.0, 25.0, 35.0],
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
vulnerability = compute_vulnerability_index(
|
|
109
|
+
df, ["unemployment_pct", "no_car_pct", "elderly_pct"]
|
|
110
|
+
)
|
|
111
|
+
flagged = identify_multiply_deprived(
|
|
112
|
+
df, ["unemployment_pct", "no_car_pct", "elderly_pct"], min_factors=2
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Documentation
|
|
117
|
+
|
|
118
|
+
- [Methodology](https://github.com/SVamseekar/moveq/blob/main/docs/methodology.md)
|
|
119
|
+
- [API reference](https://github.com/SVamseekar/moveq/blob/main/docs/api_reference.md)
|
|
120
|
+
- [Source repository](https://github.com/SVamseekar/moveq)
|
|
121
|
+
- [Changelog](https://github.com/SVamseekar/moveq/blob/main/CHANGELOG.md)
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
[BSD 3-Clause](https://github.com/SVamseekar/moveq/blob/main/LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/moveq_core/__init__.py
|
|
5
|
+
src/moveq_core/equity.py
|
|
6
|
+
src/moveq_core/frames.py
|
|
7
|
+
src/moveq_core/score.py
|
|
8
|
+
src/moveq_core.egg-info/PKG-INFO
|
|
9
|
+
src/moveq_core.egg-info/SOURCES.txt
|
|
10
|
+
src/moveq_core.egg-info/dependency_links.txt
|
|
11
|
+
src/moveq_core.egg-info/requires.txt
|
|
12
|
+
src/moveq_core.egg-info/top_level.txt
|
|
13
|
+
tests/test_equity.py
|
|
14
|
+
tests/test_frames.py
|
|
15
|
+
tests/test_score.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moveq_core
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from moveq_core import compute_concentration_index, compute_gini, compute_palma_ratio
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_gini_perfect_equality_is_zero():
|
|
8
|
+
values = np.array([10.0, 10.0, 10.0, 10.0])
|
|
9
|
+
weights = np.array([100.0, 100.0, 100.0, 100.0])
|
|
10
|
+
assert compute_gini(values, weights) == pytest.approx(0.0, abs=1e-9)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_gini_extreme_inequality_approaches_one():
|
|
14
|
+
# All service in one unit, none anywhere else.
|
|
15
|
+
values = np.array([0.0, 0.0, 0.0, 100.0])
|
|
16
|
+
weights = np.array([100.0, 100.0, 100.0, 100.0])
|
|
17
|
+
gini = compute_gini(values, weights)
|
|
18
|
+
assert 0.7 < gini < 1.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_gini_is_order_invariant():
|
|
22
|
+
values_a = np.array([5.0, 20.0, 1.0, 50.0])
|
|
23
|
+
weights_a = np.array([900.0, 800.0, 1200.0, 300.0])
|
|
24
|
+
order = [3, 1, 0, 2]
|
|
25
|
+
gini_a = compute_gini(values_a, weights_a)
|
|
26
|
+
gini_b = compute_gini(values_a[order], weights_a[order])
|
|
27
|
+
assert gini_a == pytest.approx(gini_b, abs=1e-9)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_palma_ratio_equal_service_is_one():
|
|
31
|
+
values = np.array([10.0] * 10)
|
|
32
|
+
weights = np.array([100.0] * 10)
|
|
33
|
+
assert compute_palma_ratio(values, weights) == pytest.approx(1.0, abs=1e-9)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_palma_ratio_top_heavy_is_greater_than_one():
|
|
37
|
+
values = np.array([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 100.0])
|
|
38
|
+
weights = np.array([100.0] * 10)
|
|
39
|
+
assert compute_palma_ratio(values, weights) > 1.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_concentration_index_pro_rich_when_service_rises_with_rank():
|
|
43
|
+
service = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
|
44
|
+
rank = np.array([1, 2, 3, 4, 5]) # higher rank = less deprived
|
|
45
|
+
population = np.array([100.0, 100.0, 100.0, 100.0, 100.0])
|
|
46
|
+
ci = compute_concentration_index(service, rank, population)
|
|
47
|
+
assert ci > 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_concentration_index_pro_poor_when_service_falls_with_rank():
|
|
51
|
+
service = np.array([5.0, 4.0, 3.0, 2.0, 1.0])
|
|
52
|
+
rank = np.array([1, 2, 3, 4, 5])
|
|
53
|
+
population = np.array([100.0, 100.0, 100.0, 100.0, 100.0])
|
|
54
|
+
ci = compute_concentration_index(service, rank, population)
|
|
55
|
+
assert ci < 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_concentration_index_flat_service_is_zero():
|
|
59
|
+
service = np.array([3.0, 3.0, 3.0, 3.0, 3.0])
|
|
60
|
+
rank = np.array([1, 2, 3, 4, 5])
|
|
61
|
+
population = np.array([100.0, 100.0, 100.0, 100.0, 100.0])
|
|
62
|
+
ci = compute_concentration_index(service, rank, population)
|
|
63
|
+
assert ci == pytest.approx(0.0, abs=1e-9)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from moveq_core.frames import compute_vulnerability_index, identify_multiply_deprived
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_vulnerability_index_basic():
|
|
9
|
+
df = pd.DataFrame({
|
|
10
|
+
"deprivation": [10.0, 20.0, 30.0],
|
|
11
|
+
"no_car_pct": [0.0, 50.0, 100.0],
|
|
12
|
+
"unemployment": [5.0, 10.0, 15.0],
|
|
13
|
+
})
|
|
14
|
+
# For each column:
|
|
15
|
+
# row 0: (10-10)/(30-10)=0%, (0-0)/(100-0)=0%, (5-5)/(15-5)=0% -> mean 0.0
|
|
16
|
+
# row 1: (20-10)/20=50%, (50-0)/100=50%, (10-5)/10=50% -> mean 50.0
|
|
17
|
+
# row 2: (30-10)/20=100%, (100-0)/100=100%, (15-5)/10=100% -> mean 100.0
|
|
18
|
+
vuln = compute_vulnerability_index(df, ["deprivation", "no_car_pct", "unemployment"])
|
|
19
|
+
|
|
20
|
+
assert len(vuln) == 3
|
|
21
|
+
assert vuln.iloc[0] == 0.0
|
|
22
|
+
assert vuln.iloc[1] == 50.0
|
|
23
|
+
assert vuln.iloc[2] == 100.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_vulnerability_index_constant_column():
|
|
27
|
+
df = pd.DataFrame({
|
|
28
|
+
"constant_factor": [5.0, 5.0, 5.0],
|
|
29
|
+
"variable_factor": [0.0, 50.0, 100.0],
|
|
30
|
+
})
|
|
31
|
+
# constant_factor is normalized to 0.0 because mx == mn
|
|
32
|
+
# row 0: (0 + 0) / 2 = 0.0
|
|
33
|
+
# row 1: (0 + 50) / 2 = 25.0
|
|
34
|
+
# row 2: (0 + 100) / 2 = 50.0
|
|
35
|
+
vuln = compute_vulnerability_index(df, ["constant_factor", "variable_factor"])
|
|
36
|
+
assert vuln.iloc[0] == 0.0
|
|
37
|
+
assert vuln.iloc[1] == 25.0
|
|
38
|
+
assert vuln.iloc[2] == 50.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_vulnerability_index_single_factor():
|
|
42
|
+
df = pd.DataFrame({"factor": [10.0, 30.0]})
|
|
43
|
+
vuln = compute_vulnerability_index(df, ["factor"])
|
|
44
|
+
assert vuln.iloc[0] == 0.0
|
|
45
|
+
assert vuln.iloc[1] == 100.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_identify_multiply_deprived():
|
|
49
|
+
# 4 rows, 3 factors
|
|
50
|
+
df = pd.DataFrame({
|
|
51
|
+
"f1": [1.0, 2.0, 3.0, 4.0],
|
|
52
|
+
"f2": [10.0, 20.0, 30.0, 40.0],
|
|
53
|
+
"f3": [100.0, 200.0, 300.0, 400.0],
|
|
54
|
+
"f4": [1000.0, 2000.0, 3000.0, 4000.0],
|
|
55
|
+
})
|
|
56
|
+
# Quantile(2/3):
|
|
57
|
+
# Rows 2 and 3 should be >= 2/3 quantile across all factors
|
|
58
|
+
flagged_3 = identify_multiply_deprived(df, ["f1", "f2", "f3", "f4"], min_factors=3)
|
|
59
|
+
assert not flagged_3.iloc[0]
|
|
60
|
+
assert not flagged_3.iloc[1]
|
|
61
|
+
assert flagged_3.iloc[2]
|
|
62
|
+
assert flagged_3.iloc[3]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_identify_multiply_deprived_custom_min_factors():
|
|
66
|
+
df = pd.DataFrame({
|
|
67
|
+
"f1": [1.0, 2.0, 3.0],
|
|
68
|
+
"f2": [1.0, 1.0, 3.0],
|
|
69
|
+
"f3": [1.0, 1.0, 1.0],
|
|
70
|
+
})
|
|
71
|
+
flagged_1 = identify_multiply_deprived(df, ["f1", "f2", "f3"], min_factors=1)
|
|
72
|
+
assert isinstance(flagged_1, pd.Series)
|
|
73
|
+
assert flagged_1.dtype == bool
|
|
74
|
+
assert flagged_1.iloc[2]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from moveq_core import compute_score
|
|
4
|
+
|
|
5
|
+
WEIGHTS = {"coverage": 0.40, "evening": 0.25, "frequency": 0.20, "gap": 0.15}
|
|
6
|
+
LABELS = {
|
|
7
|
+
"coverage": "Share within 400m",
|
|
8
|
+
"evening": "Evening service share",
|
|
9
|
+
"frequency": "Weekday frequency",
|
|
10
|
+
"gap": "Deprivation-service gap",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_full_terms_gives_expected_score():
|
|
15
|
+
terms = {"coverage": 1.0, "evening": 1.0, "frequency": 1.0, "gap": 1.0}
|
|
16
|
+
result = compute_score(terms, WEIGHTS, labels=LABELS)
|
|
17
|
+
assert result.score == pytest.approx(100.0)
|
|
18
|
+
assert result.dropped == []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_missing_term_is_dropped_and_weights_renormalised():
|
|
22
|
+
terms = {"coverage": 1.0, "evening": 1.0, "frequency": None, "gap": 1.0}
|
|
23
|
+
result = compute_score(terms, WEIGHTS, labels=LABELS)
|
|
24
|
+
assert result.dropped == ["frequency"]
|
|
25
|
+
# Remaining weights (0.40 + 0.25 + 0.15 = 0.80) renormalise to sum to 1.
|
|
26
|
+
used = sum(c.weight_used for c in result.components if not c.missing)
|
|
27
|
+
assert used == pytest.approx(1.0)
|
|
28
|
+
assert result.score == pytest.approx(100.0)
|
|
29
|
+
assert result.note is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_all_terms_missing_returns_none_score():
|
|
33
|
+
terms = {"coverage": None, "evening": None, "frequency": None, "gap": None}
|
|
34
|
+
result = compute_score(terms, WEIGHTS, labels=LABELS)
|
|
35
|
+
assert result.score is None
|
|
36
|
+
assert result.dropped == list(WEIGHTS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_values_are_clipped_to_unit_interval():
|
|
40
|
+
terms = {"coverage": 1.5, "evening": -0.5, "frequency": 0.5, "gap": 0.5}
|
|
41
|
+
result = compute_score(terms, WEIGHTS, labels=LABELS)
|
|
42
|
+
values = {c.id: c.value for c in result.components}
|
|
43
|
+
assert values["coverage"] == 1.0
|
|
44
|
+
assert values["evening"] == 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_to_dict_rounds_score():
|
|
48
|
+
terms = {"coverage": 0.333333, "evening": 1.0, "frequency": 1.0, "gap": 1.0}
|
|
49
|
+
result = compute_score(terms, WEIGHTS, labels=LABELS)
|
|
50
|
+
d = result.to_dict()
|
|
51
|
+
assert isinstance(d["score"], float)
|
|
52
|
+
assert d["score"] == round(d["score"], 1)
|