crashmap 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.
crashmap/__init__.py ADDED
@@ -0,0 +1,174 @@
1
+ """crashmap: road-safety network screening and crash hotspot analysis.
2
+
3
+ Implements the network screening performance measures of the Highway Safety
4
+ Manual (AASHTO 2010, Part B, Chapter 4), negative binomial safety performance
5
+ functions, Empirical Bayes estimation, before-after evaluation (Hauer 1997)
6
+ and spatial hotspot tools, on top of NumPy only.
7
+
8
+ Typical workflow::
9
+
10
+ import crashmap as cm
11
+
12
+ crashes = cm.read_crashes_csv("crashes.csv")
13
+ roads = cm.read_roads_geojson("roads.geojson")
14
+ segments = cm.fixed_length_segments(roads, 500)
15
+ snap = cm.snap_to_lines(crashes, segments, max_distance=25)
16
+ sites = cm.build_segment_sites(segments, crashes, snap, years=5)
17
+ spf = cm.SPF.segment(a=-6.2, b=0.75, k=0.5, length_unit="km")
18
+ result = cm.screen(sites, "eb", spf=spf)
19
+ cm.write_geojson(result, "ranked.geojson")
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ from .before_after import (
27
+ BeforeAfterResult,
28
+ comparison_group_before_after,
29
+ eb_before_after,
30
+ naive_before_after,
31
+ )
32
+ from .data import (
33
+ CrashSet,
34
+ Road,
35
+ crashes_from_dataframe,
36
+ read_crashes_csv,
37
+ read_crashes_geojson,
38
+ read_roads_geojson,
39
+ roads_from_geodataframe,
40
+ write_crashes_csv,
41
+ write_roads_geojson,
42
+ )
43
+ from .geometry import LocalProjection
44
+ from .nb2 import NB2Result, fit_nb2, fit_poisson, nb2_loglik
45
+ from .ranking import rank, top_n
46
+ from .report import ranked_geojson, to_html, to_markdown, write_geojson, write_report
47
+ from .screening import (
48
+ METHODS,
49
+ ScreeningResult,
50
+ average_frequency,
51
+ crash_rate,
52
+ critical_rate,
53
+ eb_expected,
54
+ eb_weight,
55
+ epdo_score,
56
+ excess_predicted,
57
+ exposure_mev,
58
+ exposure_mvkt,
59
+ proportion_exceeding,
60
+ relative_severity_index,
61
+ screen,
62
+ )
63
+ from .segmentation import (
64
+ Segment,
65
+ Window,
66
+ fixed_length_segments,
67
+ homogeneous_segments,
68
+ roads_as_segments,
69
+ sliding_window_counts,
70
+ sliding_windows,
71
+ )
72
+ from .severity import (
73
+ DEFAULT_EPDO_WEIGHTS,
74
+ HSM_CRASH_COSTS,
75
+ KABCO,
76
+ epdo_weights_from_costs,
77
+ normalize_severity,
78
+ to_fss,
79
+ )
80
+ from .sites import Site, build_intersection_sites, build_segment_sites, build_window_sites
81
+ from .snapping import Intersection, SnapResult, extract_intersections, snap_to_lines, snap_to_points
82
+ from .spatial import (
83
+ distance_band_weights,
84
+ getis_ord_gi_star,
85
+ kde_at,
86
+ kde_grid,
87
+ network_kde,
88
+ touching_weights,
89
+ )
90
+ from .spf import SPF, CUREResult, FittedSPF, SPFTerm, cure_plot, fit_spf, load_spf
91
+ from .synthetic import simulate_nb_counts, synthetic_network, synthetic_sites
92
+ from .timeparse import parse_datetime
93
+
94
+ __all__ = [
95
+ "DEFAULT_EPDO_WEIGHTS",
96
+ "HSM_CRASH_COSTS",
97
+ "KABCO",
98
+ "METHODS",
99
+ "SPF",
100
+ "BeforeAfterResult",
101
+ "CUREResult",
102
+ "CrashSet",
103
+ "FittedSPF",
104
+ "Intersection",
105
+ "LocalProjection",
106
+ "NB2Result",
107
+ "Road",
108
+ "SPFTerm",
109
+ "ScreeningResult",
110
+ "Segment",
111
+ "Site",
112
+ "SnapResult",
113
+ "Window",
114
+ "__version__",
115
+ "average_frequency",
116
+ "build_intersection_sites",
117
+ "build_segment_sites",
118
+ "build_window_sites",
119
+ "comparison_group_before_after",
120
+ "crash_rate",
121
+ "crashes_from_dataframe",
122
+ "critical_rate",
123
+ "cure_plot",
124
+ "distance_band_weights",
125
+ "eb_before_after",
126
+ "eb_expected",
127
+ "eb_weight",
128
+ "epdo_score",
129
+ "epdo_weights_from_costs",
130
+ "excess_predicted",
131
+ "exposure_mev",
132
+ "exposure_mvkt",
133
+ "extract_intersections",
134
+ "fit_nb2",
135
+ "fit_poisson",
136
+ "fit_spf",
137
+ "fixed_length_segments",
138
+ "getis_ord_gi_star",
139
+ "homogeneous_segments",
140
+ "kde_at",
141
+ "kde_grid",
142
+ "load_spf",
143
+ "naive_before_after",
144
+ "nb2_loglik",
145
+ "network_kde",
146
+ "normalize_severity",
147
+ "parse_datetime",
148
+ "proportion_exceeding",
149
+ "rank",
150
+ "ranked_geojson",
151
+ "read_crashes_csv",
152
+ "read_crashes_geojson",
153
+ "read_roads_geojson",
154
+ "relative_severity_index",
155
+ "roads_as_segments",
156
+ "roads_from_geodataframe",
157
+ "screen",
158
+ "simulate_nb_counts",
159
+ "sliding_window_counts",
160
+ "sliding_windows",
161
+ "snap_to_lines",
162
+ "snap_to_points",
163
+ "synthetic_network",
164
+ "synthetic_sites",
165
+ "to_fss",
166
+ "to_html",
167
+ "to_markdown",
168
+ "top_n",
169
+ "touching_weights",
170
+ "write_crashes_csv",
171
+ "write_geojson",
172
+ "write_report",
173
+ "write_roads_geojson",
174
+ ]
crashmap/_special.py ADDED
@@ -0,0 +1,91 @@
1
+ """Special functions without a hard SciPy dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from statistics import NormalDist
7
+
8
+ __all__ = ["betainc", "norm_cdf", "norm_ppf", "norm_sf"]
9
+
10
+ _STD = NormalDist()
11
+
12
+
13
+ def norm_ppf(p: float) -> float:
14
+ """Standard normal quantile."""
15
+ if not 0.0 < p < 1.0:
16
+ raise ValueError("probability must be within (0, 1)")
17
+ return float(_STD.inv_cdf(p))
18
+
19
+
20
+ def norm_cdf(z: float) -> float:
21
+ """Standard normal CDF."""
22
+ return 0.5 * math.erfc(-z / math.sqrt(2.0))
23
+
24
+
25
+ def norm_sf(z: float) -> float:
26
+ """Standard normal survival function (accurate in the upper tail)."""
27
+ return 0.5 * math.erfc(z / math.sqrt(2.0))
28
+
29
+
30
+ def _betacf(a: float, b: float, x: float) -> float:
31
+ # Modified Lentz continued fraction (Press et al., Numerical Recipes, 6.4).
32
+ fpmin = 1e-300
33
+ qab, qap, qam = a + b, a + 1.0, a - 1.0
34
+ c = 1.0
35
+ d = 1.0 - qab * x / qap
36
+ if abs(d) < fpmin:
37
+ d = fpmin
38
+ d = 1.0 / d
39
+ h = d
40
+ for m in range(1, 10_001):
41
+ m2 = 2 * m
42
+ aa = m * (b - m) * x / ((qam + m2) * (a + m2))
43
+ d = 1.0 + aa * d
44
+ d = fpmin if abs(d) < fpmin else d
45
+ c = 1.0 + aa / c
46
+ c = fpmin if abs(c) < fpmin else c
47
+ d = 1.0 / d
48
+ h *= d * c
49
+ aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
50
+ d = 1.0 + aa * d
51
+ d = fpmin if abs(d) < fpmin else d
52
+ c = 1.0 + aa / c
53
+ c = fpmin if abs(c) < fpmin else c
54
+ d = 1.0 / d
55
+ de = d * c
56
+ h *= de
57
+ if abs(de - 1.0) < 1e-15:
58
+ return h
59
+ raise ArithmeticError("incomplete beta continued fraction did not converge")
60
+
61
+
62
+ def _betainc_pure(a: float, b: float, x: float) -> float:
63
+ if x <= 0.0:
64
+ return 0.0
65
+ if x >= 1.0:
66
+ return 1.0
67
+ lbt = (
68
+ math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) + b * math.log1p(-x)
69
+ )
70
+ bt = math.exp(lbt)
71
+ if x < (a + 1.0) / (a + b + 2.0):
72
+ return bt * _betacf(a, b, x) / a
73
+ return 1.0 - bt * _betacf(b, a, 1.0 - x) / b
74
+
75
+
76
+ def betainc(a: float, b: float, x: float, *, use_scipy: bool = True) -> float:
77
+ """Regularised incomplete beta function :math:`I_x(a, b)` (the Beta CDF).
78
+
79
+ Uses :func:`scipy.special.betainc` when SciPy is installed and
80
+ ``use_scipy`` is true, otherwise a continued-fraction evaluation.
81
+ """
82
+ if a <= 0 or b <= 0:
83
+ raise ValueError("beta parameters must be positive")
84
+ if use_scipy:
85
+ try:
86
+ from scipy.special import betainc as _sp_betainc
87
+ except ImportError: # pragma: no cover - depends on the environment
88
+ pass
89
+ else:
90
+ return float(_sp_betainc(a, b, x))
91
+ return _betainc_pure(a, b, x)
@@ -0,0 +1,251 @@
1
+ """Before-after evaluation of safety treatments (Hauer 1997).
2
+
3
+ All three designs share Hauer's four-step framework: estimate ``pi``, the
4
+ crashes expected in the after period had the treatment not been applied, and
5
+ compare it with ``lambda``, the crashes observed after treatment. The index of
6
+ effectiveness (crash modification factor, CMF) corrected for the bias of a
7
+ ratio of random variables is::
8
+
9
+ theta = (lambda / pi) / (1 + Var(pi) / pi**2)
10
+ Var(theta) = theta**2 * (Var(lambda)/lambda**2 + Var(pi)/pi**2) / (1 + Var(pi)/pi**2)**2
11
+
12
+ with ``Var(lambda) = lambda`` (Poisson). A CMF below 1 means fewer crashes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Union
21
+
22
+ import numpy as np
23
+
24
+ from ._special import norm_ppf
25
+
26
+ __all__ = [
27
+ "BeforeAfterResult",
28
+ "comparison_group_before_after",
29
+ "eb_before_after",
30
+ "naive_before_after",
31
+ ]
32
+
33
+ ArrayLike = Union[Sequence[float], np.ndarray, float]
34
+
35
+
36
+ @dataclass
37
+ class BeforeAfterResult:
38
+ """Outcome of a before-after study.
39
+
40
+ Attributes
41
+ ----------
42
+ method:
43
+ ``"naive"``, ``"comparison-group"`` or ``"empirical-bayes"``.
44
+ observed_after:
45
+ ``lambda``, crashes observed after treatment (all sites).
46
+ expected_after:
47
+ ``pi``, crashes expected after without treatment.
48
+ var_expected_after:
49
+ ``Var(pi)``.
50
+ cmf:
51
+ Bias-corrected index of effectiveness ``theta``.
52
+ se:
53
+ Standard error of ``theta``.
54
+ """
55
+
56
+ method: str
57
+ observed_after: float
58
+ expected_after: float
59
+ var_expected_after: float
60
+ cmf: float
61
+ se: float
62
+ n_sites: int
63
+ details: dict[str, Any] = field(default_factory=dict)
64
+
65
+ @property
66
+ def percent_change(self) -> float:
67
+ """``100 * (theta - 1)``; negative = crash reduction."""
68
+ return 100.0 * (self.cmf - 1.0)
69
+
70
+ @property
71
+ def crash_reduction(self) -> float:
72
+ """Crash reduction factor ``100 * (1 - theta)`` in percent."""
73
+ return 100.0 * (1.0 - self.cmf)
74
+
75
+ def confidence_interval(self, level: float = 0.95) -> tuple[float, float]:
76
+ """Normal-approximation confidence interval of the CMF."""
77
+ z = norm_ppf(0.5 + level / 2.0)
78
+ return (self.cmf - z * self.se, self.cmf + z * self.se)
79
+
80
+ @property
81
+ def significant(self) -> bool:
82
+ """True when the 95 % confidence interval excludes 1."""
83
+ lo, hi = self.confidence_interval(0.95)
84
+ return hi < 1.0 or lo > 1.0
85
+
86
+ def as_dict(self) -> dict[str, Any]:
87
+ """Flat dictionary for reports and JSON."""
88
+ lo, hi = self.confidence_interval()
89
+ return {
90
+ "method": self.method,
91
+ "n_sites": self.n_sites,
92
+ "observed_after": self.observed_after,
93
+ "expected_after": self.expected_after,
94
+ "var_expected_after": self.var_expected_after,
95
+ "cmf": self.cmf,
96
+ "se": self.se,
97
+ "ci95_low": lo,
98
+ "ci95_high": hi,
99
+ "percent_change": self.percent_change,
100
+ "significant": self.significant,
101
+ **self.details,
102
+ }
103
+
104
+
105
+ def _hauer(lam: float, pi: float, var_pi: float) -> tuple[float, float]:
106
+ if pi <= 0:
107
+ raise ValueError("expected after-period crashes must be positive")
108
+ rel = var_pi / pi**2
109
+ theta = (lam / pi) / (1.0 + rel)
110
+ if lam > 0:
111
+ var_t = theta**2 * (1.0 / lam + rel) / (1.0 + rel) ** 2
112
+ else:
113
+ # lambda = 0 makes the Poisson estimate Var(lambda) = lambda vanish;
114
+ # use Var(lambda) = 1 (one crash) so the standard error stays honest.
115
+ var_t = (1.0 / pi**2) / (1.0 + rel) ** 2
116
+ return float(theta), float(math.sqrt(var_t))
117
+
118
+
119
+ def _total(v: ArrayLike, name: str) -> tuple[float, int]:
120
+ a = np.asarray(v, dtype=float).ravel()
121
+ if a.size == 0 or np.any(a < 0) or not np.all(np.isfinite(a)):
122
+ raise ValueError(f"{name} must be non-negative and finite")
123
+ return float(a.sum()), int(a.size)
124
+
125
+
126
+ def naive_before_after(
127
+ observed_before: ArrayLike,
128
+ observed_after: ArrayLike,
129
+ *,
130
+ years_before: float = 1.0,
131
+ years_after: float = 1.0,
132
+ ) -> BeforeAfterResult:
133
+ """Naive (simple) before-after study.
134
+
135
+ ``pi = r_d * K`` with ``K`` the before count and ``r_d`` the ratio of
136
+ after to before durations; ``Var(pi) = r_d**2 * K``. Ignores regression to
137
+ the mean and general trends, so it overstates benefits at sites chosen for
138
+ their high crash counts.
139
+ """
140
+ k, n_sites = _total(observed_before, "observed_before")
141
+ lam, n2 = _total(observed_after, "observed_after")
142
+ if n_sites != n2:
143
+ raise ValueError("before and after counts must cover the same sites")
144
+ if years_before <= 0 or years_after <= 0:
145
+ raise ValueError("period lengths must be positive")
146
+ rd = years_after / years_before
147
+ pi = rd * k
148
+ var_pi = rd**2 * k
149
+ theta, se = _hauer(lam, pi, var_pi)
150
+ return BeforeAfterResult("naive", lam, pi, var_pi, theta, se, n_sites, {"duration_ratio": rd})
151
+
152
+
153
+ def comparison_group_before_after(
154
+ observed_before: ArrayLike,
155
+ observed_after: ArrayLike,
156
+ comparison_before: ArrayLike,
157
+ comparison_after: ArrayLike,
158
+ *,
159
+ comparison_ratio_variance: float | None = None,
160
+ ) -> BeforeAfterResult:
161
+ """Before-after study with a comparison group (Hauer 1997, ch. 9).
162
+
163
+ The comparison ratio ``r_C = N / M`` (comparison after over before
164
+ crashes) scales the before count: ``pi = r_C * K``. Unless given,
165
+ ``Var(r_C) / r_C**2`` is approximated by ``1/M + 1/N`` (comparison counts
166
+ Poisson, no extra variation between treated and comparison ratios) and
167
+ ``Var(pi) = pi**2 * (1/K + Var(r_C)/r_C**2)``.
168
+ """
169
+ k, n_sites = _total(observed_before, "observed_before")
170
+ lam, n2 = _total(observed_after, "observed_after")
171
+ if n_sites != n2:
172
+ raise ValueError("before and after counts must cover the same sites")
173
+ m, _ = _total(comparison_before, "comparison_before")
174
+ nn, _ = _total(comparison_after, "comparison_after")
175
+ if m <= 0 or nn <= 0 or k <= 0:
176
+ raise ValueError("comparison counts and treated before count must be positive")
177
+ rc = nn / m
178
+ rel_rc = (
179
+ (1.0 / m + 1.0 / nn)
180
+ if comparison_ratio_variance is None
181
+ else (comparison_ratio_variance / rc**2)
182
+ )
183
+ pi = rc * k
184
+ var_pi = pi**2 * (1.0 / k + rel_rc)
185
+ theta, se = _hauer(lam, pi, var_pi)
186
+ return BeforeAfterResult(
187
+ "comparison-group",
188
+ lam,
189
+ pi,
190
+ var_pi,
191
+ theta,
192
+ se,
193
+ n_sites,
194
+ {"comparison_ratio": rc, "comparison_ratio_rel_var": rel_rc},
195
+ )
196
+
197
+
198
+ def eb_before_after(
199
+ observed_before: ArrayLike,
200
+ observed_after: ArrayLike,
201
+ predicted_before: ArrayLike,
202
+ predicted_after: ArrayLike,
203
+ k: ArrayLike,
204
+ ) -> BeforeAfterResult:
205
+ """Empirical Bayes before-after study (Hauer 1997; Persaud & Lyon 2007).
206
+
207
+ For every treated site ``i``, with SPF predictions summed over the before
208
+ (``P_b``) and after (``P_a``) periods::
209
+
210
+ w_i = 1 / (1 + k_i * P_b)
211
+ E_b = w_i * P_b + (1 - w_i) * K_i EB estimate, before period
212
+ pi_i = E_b * P_a / P_b expected after, untreated
213
+ Var(pi_i) = (P_a / P_b)**2 * (1 - w_i) * E_b
214
+
215
+ Sums over sites give ``pi`` and ``Var(pi)``; ``lambda`` is the summed
216
+ after count. ``k`` may be one value or one per site (e.g. ``k / L_i``).
217
+ """
218
+ kb = np.asarray(observed_before, dtype=float).ravel()
219
+ la = np.asarray(observed_after, dtype=float).ravel()
220
+ pb = np.asarray(predicted_before, dtype=float).ravel()
221
+ pa = np.asarray(predicted_after, dtype=float).ravel()
222
+ kk = np.broadcast_to(np.asarray(k, dtype=float), kb.shape)
223
+ if not (kb.shape == la.shape == pb.shape == pa.shape):
224
+ raise ValueError("all per-site arrays must have the same length")
225
+ if np.any(pb <= 0) or np.any(pa <= 0):
226
+ raise ValueError("predicted crashes must be positive")
227
+ if np.any(kb < 0) or np.any(la < 0) or np.any(kk < 0):
228
+ raise ValueError("counts and k must not be negative")
229
+ w = 1.0 / (1.0 + kk * pb)
230
+ eb_before = w * pb + (1.0 - w) * kb
231
+ ratio = pa / pb
232
+ pi_i = eb_before * ratio
233
+ var_i = ratio**2 * (1.0 - w) * eb_before
234
+ pi = float(pi_i.sum())
235
+ var_pi = float(var_i.sum())
236
+ lam = float(la.sum())
237
+ theta, se = _hauer(lam, pi, var_pi)
238
+ return BeforeAfterResult(
239
+ "empirical-bayes",
240
+ lam,
241
+ pi,
242
+ var_pi,
243
+ theta,
244
+ se,
245
+ int(kb.size),
246
+ {
247
+ "weights": w.tolist(),
248
+ "eb_before": eb_before.tolist(),
249
+ "expected_after_by_site": pi_i.tolist(),
250
+ },
251
+ )