tripdist 0.1.0__py3-none-any.whl

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.
tripdist/__init__.py ADDED
@@ -0,0 +1,131 @@
1
+ """tripdist: trip generation and distribution (stages 1-2 of the four-step model).
2
+
3
+ Trip generation by regression and cross-classification with production/attraction
4
+ balancing; growth-factor methods (uniform, singly constrained, average, Detroit, Fratar,
5
+ Furness); gravity models (unconstrained, production-, attraction- and doubly constrained)
6
+ with exponential, power, combined (Tanner), gamma and tabulated deterrence functions;
7
+ Hyman's (1969) calibration to an observed mean trip cost; trip length frequency
8
+ distributions, K-factors, intrazonal handling and convergence diagnostics. Methods follow
9
+ Ortúzar & Willumsen, *Modelling Transport* (4th ed., 2011).
10
+ """
11
+
12
+ from ._common import TripDistError
13
+ from ._version import __version__
14
+ from .balancing import BalanceResult, furness, margin_error
15
+ from .calibration import (
16
+ CalibrationResult,
17
+ FrictionCalibration,
18
+ calibrate_friction_factors,
19
+ hyman,
20
+ k_factors,
21
+ observed_mean_cost,
22
+ )
23
+ from .deterrence import (
24
+ DETERRENCE_FORMS,
25
+ Deterrence,
26
+ combined,
27
+ deterrence_from_dict,
28
+ exponential,
29
+ gamma,
30
+ power,
31
+ tabular,
32
+ tanner,
33
+ )
34
+ from .diagnostics import MatrixFit, MatrixSummary, aggregate, compare_matrices, geh, summarize
35
+ from .generation import (
36
+ CategoryRates,
37
+ RegressionModel,
38
+ apply_category_rates,
39
+ balance_pa,
40
+ cross_classification_rates,
41
+ fit_regression,
42
+ )
43
+ from .gravity import (
44
+ CONSTRAINTS,
45
+ GravityResult,
46
+ gravity,
47
+ intrazonal_costs,
48
+ intrazonal_share,
49
+ mean_cost,
50
+ )
51
+ from .growth import (
52
+ GROWTH_METHODS,
53
+ GrowthResult,
54
+ average_factor,
55
+ detroit,
56
+ fratar,
57
+ furness_growth,
58
+ growth_factor,
59
+ singly_constrained_growth,
60
+ uniform_growth,
61
+ )
62
+ from .io import read_json, read_long, read_matrix, read_zone_table, write_json, write_matrix
63
+ from .tlfd import TLFD, TLFDComparison, coincidence_ratio, compare_tlfd, cost_bins, tlfd
64
+
65
+ __all__ = [
66
+ "__version__",
67
+ "TripDistError",
68
+ # generation
69
+ "fit_regression",
70
+ "RegressionModel",
71
+ "cross_classification_rates",
72
+ "apply_category_rates",
73
+ "CategoryRates",
74
+ "balance_pa",
75
+ # balancing and growth factors
76
+ "furness",
77
+ "margin_error",
78
+ "BalanceResult",
79
+ "uniform_growth",
80
+ "singly_constrained_growth",
81
+ "average_factor",
82
+ "detroit",
83
+ "fratar",
84
+ "furness_growth",
85
+ "growth_factor",
86
+ "GrowthResult",
87
+ "GROWTH_METHODS",
88
+ # deterrence and gravity
89
+ "Deterrence",
90
+ "exponential",
91
+ "power",
92
+ "combined",
93
+ "tanner",
94
+ "gamma",
95
+ "tabular",
96
+ "deterrence_from_dict",
97
+ "DETERRENCE_FORMS",
98
+ "gravity",
99
+ "GravityResult",
100
+ "CONSTRAINTS",
101
+ "mean_cost",
102
+ "intrazonal_costs",
103
+ "intrazonal_share",
104
+ # calibration
105
+ "hyman",
106
+ "observed_mean_cost",
107
+ "CalibrationResult",
108
+ "calibrate_friction_factors",
109
+ "FrictionCalibration",
110
+ "k_factors",
111
+ # TLFD and diagnostics
112
+ "tlfd",
113
+ "TLFD",
114
+ "compare_tlfd",
115
+ "TLFDComparison",
116
+ "coincidence_ratio",
117
+ "cost_bins",
118
+ "summarize",
119
+ "MatrixSummary",
120
+ "compare_matrices",
121
+ "MatrixFit",
122
+ "geh",
123
+ "aggregate",
124
+ # I/O
125
+ "read_matrix",
126
+ "write_matrix",
127
+ "read_zone_table",
128
+ "read_long",
129
+ "read_json",
130
+ "write_json",
131
+ ]
tripdist/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m tripdist``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
tripdist/_common.py ADDED
@@ -0,0 +1,106 @@
1
+ """Shared validation helpers and the package exception."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any, Union
7
+
8
+ import numpy as np
9
+ from numpy.typing import ArrayLike, NDArray
10
+
11
+ FloatArray = NDArray[np.float64]
12
+ Number = Union[int, float]
13
+
14
+
15
+ class TripDistError(ValueError):
16
+ """Raised for invalid input to a tripdist function."""
17
+
18
+
19
+ def as_vector(values: ArrayLike, name: str, *, allow_negative: bool = False) -> FloatArray:
20
+ """Return ``values`` as a finite 1-D float array or raise :class:`TripDistError`."""
21
+ try:
22
+ arr = np.asarray(values, dtype=np.float64)
23
+ except (TypeError, ValueError):
24
+ raise TripDistError(f"{name} must be numeric") from None
25
+ if arr.ndim == 0:
26
+ arr = arr.reshape(1)
27
+ if arr.ndim != 1:
28
+ raise TripDistError(f"{name} must be one-dimensional, got shape {arr.shape}")
29
+ if arr.size == 0:
30
+ raise TripDistError(f"{name} is empty")
31
+ if not np.all(np.isfinite(arr)):
32
+ raise TripDistError(f"{name} contains NaN or infinite values")
33
+ if not allow_negative and np.any(arr < 0):
34
+ raise TripDistError(f"{name} contains negative values")
35
+ return arr
36
+
37
+
38
+ def as_matrix(
39
+ values: ArrayLike,
40
+ name: str,
41
+ *,
42
+ square: bool = False,
43
+ allow_negative: bool = False,
44
+ allow_inf: bool = False,
45
+ ) -> FloatArray:
46
+ """Return ``values`` as a 2-D float array or raise :class:`TripDistError`.
47
+
48
+ ``allow_inf`` permits ``+inf`` entries, used for costs of impossible movements.
49
+ """
50
+ try:
51
+ arr = np.asarray(values, dtype=np.float64)
52
+ except (TypeError, ValueError):
53
+ raise TripDistError(f"{name} must be numeric") from None
54
+ if arr.ndim != 2:
55
+ raise TripDistError(f"{name} must be two-dimensional, got shape {arr.shape}")
56
+ if arr.size == 0:
57
+ raise TripDistError(f"{name} is empty")
58
+ if square and arr.shape[0] != arr.shape[1]:
59
+ raise TripDistError(f"{name} must be square, got shape {arr.shape}")
60
+ if np.any(np.isnan(arr)):
61
+ raise TripDistError(f"{name} contains NaN values")
62
+ if allow_inf:
63
+ if np.any(arr == -np.inf):
64
+ raise TripDistError(f"{name} contains -inf")
65
+ elif not np.all(np.isfinite(arr)):
66
+ raise TripDistError(f"{name} contains infinite values")
67
+ if not allow_negative and np.any(arr < 0):
68
+ raise TripDistError(f"{name} contains negative values")
69
+ return arr
70
+
71
+
72
+ def check_length(arr: FloatArray, n: int, name: str) -> None:
73
+ if arr.shape[0] != n:
74
+ raise TripDistError(f"{name} has {arr.shape[0]} entries, expected {n}")
75
+
76
+
77
+ def clean(value: Any) -> Any:
78
+ """Make ``value`` JSON-safe: arrays to lists, NaN/inf to ``None``."""
79
+ if isinstance(value, dict):
80
+ return {str(k): clean(v) for k, v in value.items()}
81
+ if isinstance(value, (list, tuple)):
82
+ return [clean(v) for v in value]
83
+ if isinstance(value, np.ndarray):
84
+ return clean(value.tolist())
85
+ if isinstance(value, (np.bool_, bool)):
86
+ return bool(value)
87
+ if isinstance(value, (np.integer,)):
88
+ return int(value)
89
+ if isinstance(value, (float, np.floating)):
90
+ f = float(value)
91
+ return f if math.isfinite(f) else None
92
+ return value
93
+
94
+
95
+ def safe_div(num: FloatArray, den: FloatArray) -> FloatArray:
96
+ """Element-wise ``num / den`` with 0 where ``den == 0``."""
97
+ out = np.zeros(np.broadcast(num, den).shape, dtype=np.float64)
98
+ np.divide(num, den, out=out, where=den != 0)
99
+ return out
100
+
101
+
102
+ def rel_error(actual: FloatArray, target: FloatArray) -> float:
103
+ """Largest ``|actual - target| / target`` (absolute difference where target is 0)."""
104
+ diff = np.abs(actual - target)
105
+ err = np.where(target > 0, diff / np.where(target > 0, target, 1.0), diff)
106
+ return float(err.max()) if err.size else 0.0
tripdist/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
tripdist/balancing.py ADDED
@@ -0,0 +1,219 @@
1
+ """Biproportional matrix balancing (Furness method / iterative proportional fitting).
2
+
3
+ The Furness (1965) iteration alternately scales the rows and the columns of a seed matrix
4
+ until its margins match target origin and destination totals:
5
+
6
+ T_ij = A_i O_i B_j D_j t_ij (Furness 1965; Ortúzar & Willumsen 2011, section 5.2)
7
+
8
+ The same algorithm is known as iterative proportional fitting (Deming & Stephan 1940) and
9
+ RAS (Bacharach 1970). It is the balancing engine for the Furness growth-factor method and
10
+ for the doubly constrained gravity model.
11
+
12
+ References
13
+ ----------
14
+ Bacharach, M. (1970). *Biproportional Matrices and Input-Output Change*. Cambridge
15
+ University Press.
16
+
17
+ Deming, W. E. & Stephan, F. F. (1940). On a least squares adjustment of a sampled frequency
18
+ table when the expected marginal totals are known. *Annals of Mathematical Statistics*
19
+ 11(4), 427-444.
20
+
21
+ Furness, K. P. (1965). Time function iteration. *Traffic Engineering and Control* 7(7),
22
+ 458-460.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ import numpy as np
31
+ from numpy.typing import ArrayLike
32
+
33
+ from ._common import FloatArray, TripDistError, as_matrix, as_vector, check_length, clean, rel_error
34
+
35
+ __all__ = ["BalanceResult", "furness", "margin_error"]
36
+
37
+
38
+ @dataclass
39
+ class BalanceResult:
40
+ """Outcome of a biproportional balancing run.
41
+
42
+ ``history`` holds the largest relative margin error after each iteration, which is
43
+ the convergence diagnostic usually plotted for Furness runs.
44
+ """
45
+
46
+ matrix: FloatArray
47
+ row_factors: FloatArray
48
+ col_factors: FloatArray
49
+ iterations: int
50
+ converged: bool
51
+ max_error: float
52
+ history: List[float] = field(default_factory=list)
53
+
54
+ @property
55
+ def total(self) -> float:
56
+ return float(self.matrix.sum())
57
+
58
+ def to_dict(self, include_matrix: bool = True) -> Dict[str, Any]:
59
+ out: Dict[str, Any] = {
60
+ "iterations": self.iterations,
61
+ "converged": self.converged,
62
+ "max_error": self.max_error,
63
+ "total": self.total,
64
+ "row_factors": self.row_factors,
65
+ "col_factors": self.col_factors,
66
+ "history": self.history,
67
+ }
68
+ if include_matrix:
69
+ out["matrix"] = self.matrix
70
+ return clean(out)
71
+
72
+
73
+ def margin_error(matrix: ArrayLike, row_targets: ArrayLike, col_targets: ArrayLike) -> float:
74
+ """Largest relative difference between a matrix's margins and the targets.
75
+
76
+ Each error is ``|sum - target| / target``; targets of zero use the absolute sum.
77
+ """
78
+ m = as_matrix(matrix, "matrix")
79
+ rt = as_vector(row_targets, "row_targets")
80
+ ct = as_vector(col_targets, "col_targets")
81
+ check_length(rt, m.shape[0], "row_targets")
82
+ check_length(ct, m.shape[1], "col_targets")
83
+ return _margin_error(m, rt, ct)
84
+
85
+
86
+ def _margin_error(m: FloatArray, rt: FloatArray, ct: FloatArray) -> float:
87
+ return max(rel_error(m.sum(axis=1), rt), rel_error(m.sum(axis=0), ct))
88
+
89
+
90
+ def furness(
91
+ seed: ArrayLike,
92
+ row_targets: ArrayLike,
93
+ col_targets: ArrayLike,
94
+ *,
95
+ tol: float = 1e-6,
96
+ max_iter: int = 1000,
97
+ total_mismatch: str = "error",
98
+ rel_tol_totals: float = 1e-6,
99
+ raise_on_fail: bool = False,
100
+ ) -> BalanceResult:
101
+ """Scale ``seed`` so that its row and column sums match the targets.
102
+
103
+ Parameters
104
+ ----------
105
+ seed:
106
+ Non-negative seed matrix (base-year trips, or ``O_i D_j f(c_ij)`` for a gravity
107
+ model). Cells that are zero stay zero.
108
+ row_targets, col_targets:
109
+ Target origin (row) and destination (column) totals.
110
+ tol:
111
+ Stop when the largest relative margin error falls below this value.
112
+ max_iter:
113
+ Maximum number of row-plus-column passes.
114
+ total_mismatch:
115
+ What to do when the two target vectors have different totals (the problem then has
116
+ no exact solution): ``"error"`` (default), ``"rows"`` scale the column targets to
117
+ the row total, ``"cols"`` scale the row targets to the column total, or
118
+ ``"average"`` scale both to their mean.
119
+ raise_on_fail:
120
+ Raise :class:`TripDistError` if the run has not converged after ``max_iter``.
121
+
122
+ The iteration converges when a solution exists, i.e. when the zero pattern of the seed
123
+ allows the targets (Bacharach 1970). Rows or columns of the seed that are entirely zero
124
+ but have a positive target make that impossible and raise an error.
125
+ """
126
+ t = as_matrix(seed, "seed")
127
+ rt = as_vector(row_targets, "row_targets")
128
+ ct = as_vector(col_targets, "col_targets")
129
+ check_length(rt, t.shape[0], "row_targets")
130
+ check_length(ct, t.shape[1], "col_targets")
131
+ if tol <= 0:
132
+ raise TripDistError("tol must be positive")
133
+ if max_iter < 1:
134
+ raise TripDistError("max_iter must be at least 1")
135
+ sr, sc = float(rt.sum()), float(ct.sum())
136
+ if sr <= 0 or sc <= 0:
137
+ raise TripDistError("targets must have positive totals")
138
+ if abs(sr - sc) > rel_tol_totals * max(sr, sc):
139
+ if total_mismatch == "error":
140
+ raise TripDistError(
141
+ f"row targets total {sr:g} but column targets total {sc:g}; "
142
+ "balance them first or pass total_mismatch"
143
+ )
144
+ if total_mismatch == "rows":
145
+ ct = ct * (sr / sc)
146
+ elif total_mismatch == "cols":
147
+ rt = rt * (sc / sr)
148
+ elif total_mismatch == "average":
149
+ avg = 0.5 * (sr + sc)
150
+ rt = rt * (avg / sr)
151
+ ct = ct * (avg / sc)
152
+ else:
153
+ raise TripDistError(f"unknown total_mismatch {total_mismatch!r}")
154
+ if np.any((t.sum(axis=1) == 0) & (rt > 0)):
155
+ bad = np.flatnonzero((t.sum(axis=1) == 0) & (rt > 0)).tolist()
156
+ raise TripDistError(f"seed rows {bad} are all zero but have positive targets")
157
+ if np.any((t.sum(axis=0) == 0) & (ct > 0)):
158
+ bad = np.flatnonzero((t.sum(axis=0) == 0) & (ct > 0)).tolist()
159
+ raise TripDistError(f"seed columns {bad} are all zero but have positive targets")
160
+
161
+ a = np.ones(t.shape[0])
162
+ b = np.ones(t.shape[1])
163
+ history: List[float] = []
164
+ converged = False
165
+ err = _margin_error(t, rt, ct)
166
+ it = 0
167
+ for it in range(1, max_iter + 1): # noqa: B007 - it is reported after the loop
168
+ rows = t @ b
169
+ a = np.divide(rt, rows, out=np.zeros_like(rt), where=rows > 0)
170
+ cols = a @ t
171
+ b = np.divide(ct, cols, out=np.zeros_like(ct), where=cols > 0)
172
+ m = a[:, None] * t * b[None, :]
173
+ err = _margin_error(m, rt, ct)
174
+ history.append(err)
175
+ if err < tol:
176
+ converged = True
177
+ break
178
+ matrix = a[:, None] * t * b[None, :]
179
+ if not converged and raise_on_fail:
180
+ raise TripDistError(f"Furness did not converge in {max_iter} iterations (error {err:g})")
181
+ return BalanceResult(
182
+ matrix=matrix,
183
+ row_factors=a,
184
+ col_factors=b,
185
+ iterations=it,
186
+ converged=converged,
187
+ max_error=err,
188
+ history=history,
189
+ )
190
+
191
+
192
+ def furness_from(
193
+ seed: ArrayLike,
194
+ row_targets: Optional[ArrayLike],
195
+ col_targets: Optional[ArrayLike],
196
+ **kwargs: Any,
197
+ ) -> BalanceResult:
198
+ """Furness with optional margins: a missing side is left free (single constraint)."""
199
+ t = as_matrix(seed, "seed")
200
+ if row_targets is not None and col_targets is not None:
201
+ return furness(t, row_targets, col_targets, **kwargs)
202
+ if row_targets is None and col_targets is None:
203
+ raise TripDistError("give at least one of row_targets and col_targets")
204
+ if row_targets is not None:
205
+ rt = as_vector(row_targets, "row_targets")
206
+ check_length(rt, t.shape[0], "row_targets")
207
+ rows = t.sum(axis=1)
208
+ a = np.divide(rt, rows, out=np.zeros_like(rt), where=rows > 0)
209
+ m = a[:, None] * t
210
+ err = rel_error(m.sum(axis=1), rt)
211
+ return BalanceResult(m, a, np.ones(t.shape[1]), 1, True, err, [err])
212
+ assert col_targets is not None
213
+ ct = as_vector(col_targets, "col_targets")
214
+ check_length(ct, t.shape[1], "col_targets")
215
+ cols = t.sum(axis=0)
216
+ b = np.divide(ct, cols, out=np.zeros_like(ct), where=cols > 0)
217
+ m = t * b[None, :]
218
+ err = rel_error(m.sum(axis=0), ct)
219
+ return BalanceResult(m, np.ones(t.shape[0]), b, 1, True, err, [err])