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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/moveq-core.svg)](https://pypi.org/project/moveq-core/)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/moveq-core.svg)](https://pypi.org/project/moveq-core/)
41
+ [![License](https://img.shields.io/pypi/l/moveq-core.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/moveq-core.svg)](https://pypi.org/project/moveq-core/)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/moveq-core.svg)](https://pypi.org/project/moveq-core/)
7
+ [![License](https://img.shields.io/pypi/l/moveq-core.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/moveq-core.svg)](https://pypi.org/project/moveq-core/)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/moveq-core.svg)](https://pypi.org/project/moveq-core/)
41
+ [![License](https://img.shields.io/pypi/l/moveq-core.svg)](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,7 @@
1
+ numpy>=1.24
2
+
3
+ [frames]
4
+ pandas>=2.0
5
+
6
+ [test]
7
+ pytest>=7
@@ -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)