zero2route3d-sdk 0.2.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.
- zero2route3d/__init__.py +212 -0
- zero2route3d/accessibility_equity.py +214 -0
- zero2route3d/ahp_engine.py +117 -0
- zero2route3d/api.py +244 -0
- zero2route3d/basemap.py +44 -0
- zero2route3d/cli.py +251 -0
- zero2route3d/copernicus_dem.py +76 -0
- zero2route3d/copernicus_eo_suite.py +443 -0
- zero2route3d/dem_fetcher.py +223 -0
- zero2route3d/environmental_raster.py +411 -0
- zero2route3d/evacuation.py +121 -0
- zero2route3d/html_bundler.py +603 -0
- zero2route3d/input_validation.py +100 -0
- zero2route3d/integrations.py +260 -0
- zero2route3d/isochrone_engine.py +220 -0
- zero2route3d/kinematics.py +383 -0
- zero2route3d/local_server.py +116 -0
- zero2route3d/map_matching_3d.py +374 -0
- zero2route3d/micro_elevation.py +264 -0
- zero2route3d/mobility_profiles.py +517 -0
- zero2route3d/multimodal.py +172 -0
- zero2route3d/network_source.py +512 -0
- zero2route3d/osm_downloader.py +328 -0
- zero2route3d/osm_styling.py +351 -0
- zero2route3d/pareto_router.py +331 -0
- zero2route3d/plotting.py +292 -0
- zero2route3d/profile_dxf.py +231 -0
- zero2route3d/profile_stats.py +648 -0
- zero2route3d/py.typed +1 -0
- zero2route3d/qml_generator.py +189 -0
- zero2route3d/raster_source.py +206 -0
- zero2route3d/report_generator.py +154 -0
- zero2route3d/route_corridor_3d.py +479 -0
- zero2route3d/routing_engine.py +747 -0
- zero2route3d/solar_shadow.py +157 -0
- zero2route3d/tsp_solver.py +78 -0
- zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
- zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
- zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
- zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- zero2route3d_sdk-0.2.0.dist-info/top_level.txt +1 -0
zero2route3d/__init__.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""02Route 3D SDK — Multi-Criteria 3D Kinematic & Spatial Mobility Analytics Library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.2.0"
|
|
6
|
+
__author__ = "Yusuf Eminoğlu"
|
|
7
|
+
__email__ = "yusufeminoglu@gmail.com"
|
|
8
|
+
|
|
9
|
+
from .accessibility_equity import (
|
|
10
|
+
AccessibilityEquityEngine,
|
|
11
|
+
EquityScorecardResult,
|
|
12
|
+
SupplyFacility,
|
|
13
|
+
ZoneAccessibilityRecord,
|
|
14
|
+
)
|
|
15
|
+
from .ahp_engine import AHPEngine, AHPResult
|
|
16
|
+
from .api import (
|
|
17
|
+
match_gps_track_3d,
|
|
18
|
+
solve_3d_isochrones,
|
|
19
|
+
solve_3d_route,
|
|
20
|
+
solve_4d_pareto_frontier,
|
|
21
|
+
)
|
|
22
|
+
from .basemap import add_osm_basemap
|
|
23
|
+
from .copernicus_dem import CopernicusDemError, CopernicusDemTileSource
|
|
24
|
+
from .dem_fetcher import NODATA, GlobalDemFetcher
|
|
25
|
+
from .environmental_raster import EnvironmentalSurfaceSampler, MCDAWeights
|
|
26
|
+
from .evacuation import EvacuationPlan, EvacuationRouter, HazardZone
|
|
27
|
+
from .html_bundler import StandaloneHtmlBundler
|
|
28
|
+
from .integrations import (
|
|
29
|
+
from_geodataframe,
|
|
30
|
+
sample_rasterio_dem,
|
|
31
|
+
to_geodataframe,
|
|
32
|
+
to_networkx_digraph,
|
|
33
|
+
to_shapely_linestring,
|
|
34
|
+
)
|
|
35
|
+
from .isochrone_engine import IsochroneBand, IsochroneEngine3D, IsochroneResult
|
|
36
|
+
from .kinematics import (
|
|
37
|
+
aerodynamic_drag_power,
|
|
38
|
+
cycling_energy_cost,
|
|
39
|
+
cyclist_speed,
|
|
40
|
+
haversine_distance_2d,
|
|
41
|
+
haversine_distance_3d,
|
|
42
|
+
minetti_energy_cost,
|
|
43
|
+
rolling_resistance_force,
|
|
44
|
+
scooter_speed,
|
|
45
|
+
senior_fatigue_decay,
|
|
46
|
+
solar_irradiance_aspect_factor,
|
|
47
|
+
tobler_walking_speed,
|
|
48
|
+
universal_thermal_comfort_utci,
|
|
49
|
+
vehicle_free_flow_speed,
|
|
50
|
+
)
|
|
51
|
+
from .map_matching_3d import (
|
|
52
|
+
GPXPoint,
|
|
53
|
+
HMMMapMatcher3D,
|
|
54
|
+
MapMatching3DResult,
|
|
55
|
+
MatchedTrackPoint3D,
|
|
56
|
+
)
|
|
57
|
+
from .micro_elevation import (
|
|
58
|
+
BicubicSurfaceInterpolator,
|
|
59
|
+
IDWSurfaceInterpolator,
|
|
60
|
+
MicroElevationEngine,
|
|
61
|
+
SurfaceGradient3D,
|
|
62
|
+
)
|
|
63
|
+
from .mobility_profiles import (
|
|
64
|
+
PROFILE_CATEGORIES,
|
|
65
|
+
PROFILES,
|
|
66
|
+
MobilityProfile,
|
|
67
|
+
UnknownProfileError,
|
|
68
|
+
get_profile,
|
|
69
|
+
get_profile_color,
|
|
70
|
+
list_profile_keys,
|
|
71
|
+
list_profile_keys_for_group,
|
|
72
|
+
load_custom_profile_json,
|
|
73
|
+
resolve_profile,
|
|
74
|
+
save_custom_profile_json,
|
|
75
|
+
)
|
|
76
|
+
from .multimodal import MultiModalJourney, MultiModalLeg, MultiModalRouter
|
|
77
|
+
from .network_source import NetworkSourceError, NetworkSourceManager, RoadSegment
|
|
78
|
+
from .osm_downloader import OsmBuilding, OsmDataFetcher
|
|
79
|
+
from .pareto_router import (
|
|
80
|
+
ParetoCostVector,
|
|
81
|
+
ParetoFrontierResult,
|
|
82
|
+
ParetoMultiObjectiveRouter,
|
|
83
|
+
ParetoRouteSolution,
|
|
84
|
+
)
|
|
85
|
+
from .plotting import (
|
|
86
|
+
plot_elevation_profile,
|
|
87
|
+
plot_lorenz_equity_curve,
|
|
88
|
+
plot_pareto_frontier_2d,
|
|
89
|
+
)
|
|
90
|
+
from .profile_dxf import export_route_to_dxf_3d
|
|
91
|
+
from .profile_stats import (
|
|
92
|
+
CueInstruction,
|
|
93
|
+
RouteStatistics,
|
|
94
|
+
compute_route_statistics,
|
|
95
|
+
densify_3d_linestring,
|
|
96
|
+
generate_cue_sheet,
|
|
97
|
+
smooth_elevation_series,
|
|
98
|
+
)
|
|
99
|
+
from .qml_generator import generate_route_qml_style
|
|
100
|
+
from .raster_source import GeoTiffRasterSource, RasterSourceError
|
|
101
|
+
from .report_generator import generate_analytical_report_html
|
|
102
|
+
from .route_corridor_3d import filter_buildings_in_corridor
|
|
103
|
+
from .routing_engine import RouteResult3D, RoutingEngine3D, Waypoint
|
|
104
|
+
from .solar_shadow import (
|
|
105
|
+
ShadeExposureReport,
|
|
106
|
+
SolarPosition,
|
|
107
|
+
calculate_solar_position,
|
|
108
|
+
compute_shade_exposure_along_route,
|
|
109
|
+
)
|
|
110
|
+
from .tsp_solver import solve_tsp_order
|
|
111
|
+
|
|
112
|
+
__all__ = [
|
|
113
|
+
"__version__",
|
|
114
|
+
"__author__",
|
|
115
|
+
"__email__",
|
|
116
|
+
"AccessibilityEquityEngine",
|
|
117
|
+
"EquityScorecardResult",
|
|
118
|
+
"SupplyFacility",
|
|
119
|
+
"ZoneAccessibilityRecord",
|
|
120
|
+
"AHPEngine",
|
|
121
|
+
"AHPResult",
|
|
122
|
+
"add_osm_basemap",
|
|
123
|
+
"solve_3d_route",
|
|
124
|
+
"solve_3d_isochrones",
|
|
125
|
+
"solve_4d_pareto_frontier",
|
|
126
|
+
"match_gps_track_3d",
|
|
127
|
+
"to_geodataframe",
|
|
128
|
+
"from_geodataframe",
|
|
129
|
+
"to_networkx_digraph",
|
|
130
|
+
"to_shapely_linestring",
|
|
131
|
+
"sample_rasterio_dem",
|
|
132
|
+
"plot_elevation_profile",
|
|
133
|
+
"plot_pareto_frontier_2d",
|
|
134
|
+
"plot_lorenz_equity_curve",
|
|
135
|
+
"CopernicusDemError",
|
|
136
|
+
"CopernicusDemTileSource",
|
|
137
|
+
"GlobalDemFetcher",
|
|
138
|
+
"GeoTiffRasterSource",
|
|
139
|
+
"RasterSourceError",
|
|
140
|
+
"NODATA",
|
|
141
|
+
"EnvironmentalSurfaceSampler",
|
|
142
|
+
"MCDAWeights",
|
|
143
|
+
"EvacuationPlan",
|
|
144
|
+
"EvacuationRouter",
|
|
145
|
+
"HazardZone",
|
|
146
|
+
"StandaloneHtmlBundler",
|
|
147
|
+
"IsochroneBand",
|
|
148
|
+
"IsochroneEngine3D",
|
|
149
|
+
"IsochroneResult",
|
|
150
|
+
"GPXPoint",
|
|
151
|
+
"HMMMapMatcher3D",
|
|
152
|
+
"MapMatching3DResult",
|
|
153
|
+
"MatchedTrackPoint3D",
|
|
154
|
+
"BicubicSurfaceInterpolator",
|
|
155
|
+
"IDWSurfaceInterpolator",
|
|
156
|
+
"MicroElevationEngine",
|
|
157
|
+
"SurfaceGradient3D",
|
|
158
|
+
"MultiModalJourney",
|
|
159
|
+
"MultiModalLeg",
|
|
160
|
+
"MultiModalRouter",
|
|
161
|
+
"ParetoCostVector",
|
|
162
|
+
"ParetoFrontierResult",
|
|
163
|
+
"ParetoMultiObjectiveRouter",
|
|
164
|
+
"ParetoRouteSolution",
|
|
165
|
+
"export_route_to_dxf_3d",
|
|
166
|
+
"generate_analytical_report_html",
|
|
167
|
+
"generate_route_qml_style",
|
|
168
|
+
"calculate_solar_position",
|
|
169
|
+
"compute_shade_exposure_along_route",
|
|
170
|
+
"ShadeExposureReport",
|
|
171
|
+
"SolarPosition",
|
|
172
|
+
"aerodynamic_drag_power",
|
|
173
|
+
"cyclist_speed",
|
|
174
|
+
"haversine_distance_2d",
|
|
175
|
+
"haversine_distance_3d",
|
|
176
|
+
"cycling_energy_cost",
|
|
177
|
+
"minetti_energy_cost",
|
|
178
|
+
"rolling_resistance_force",
|
|
179
|
+
"scooter_speed",
|
|
180
|
+
"senior_fatigue_decay",
|
|
181
|
+
"solar_irradiance_aspect_factor",
|
|
182
|
+
"tobler_walking_speed",
|
|
183
|
+
"universal_thermal_comfort_utci",
|
|
184
|
+
"vehicle_free_flow_speed",
|
|
185
|
+
"PROFILES",
|
|
186
|
+
"PROFILE_CATEGORIES",
|
|
187
|
+
"MobilityProfile",
|
|
188
|
+
"UnknownProfileError",
|
|
189
|
+
"get_profile",
|
|
190
|
+
"get_profile_color",
|
|
191
|
+
"list_profile_keys",
|
|
192
|
+
"list_profile_keys_for_group",
|
|
193
|
+
"resolve_profile",
|
|
194
|
+
"load_custom_profile_json",
|
|
195
|
+
"save_custom_profile_json",
|
|
196
|
+
"NetworkSourceManager",
|
|
197
|
+
"NetworkSourceError",
|
|
198
|
+
"RoadSegment",
|
|
199
|
+
"OsmBuilding",
|
|
200
|
+
"OsmDataFetcher",
|
|
201
|
+
"filter_buildings_in_corridor",
|
|
202
|
+
"CueInstruction",
|
|
203
|
+
"RouteStatistics",
|
|
204
|
+
"compute_route_statistics",
|
|
205
|
+
"densify_3d_linestring",
|
|
206
|
+
"generate_cue_sheet",
|
|
207
|
+
"smooth_elevation_series",
|
|
208
|
+
"RouteResult3D",
|
|
209
|
+
"RoutingEngine3D",
|
|
210
|
+
"Waypoint",
|
|
211
|
+
"solve_tsp_order",
|
|
212
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Urban Mobility Accessibility Equity Scorecard Engine.
|
|
2
|
+
|
|
3
|
+
Implements:
|
|
4
|
+
1. Enhanced Two-Step Floating Catchment Area (E2SFCA) with continuous Gaussian decay.
|
|
5
|
+
2. Spatial Gini Coefficient & Lorenz Curve generation for accessibility justice.
|
|
6
|
+
3. Theil Index entropy decomposition for spatial disparities.
|
|
7
|
+
4. Automated equity classification (Transit Deserts, Vulnerable, Oasis).
|
|
8
|
+
|
|
9
|
+
Author: Yusuf Eminoglu
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ZoneAccessibilityRecord:
|
|
21
|
+
"""Individual zone demographic and computed spatial accessibility indicators."""
|
|
22
|
+
|
|
23
|
+
zone_id: str
|
|
24
|
+
name: str
|
|
25
|
+
lon: float
|
|
26
|
+
lat: float
|
|
27
|
+
population: float
|
|
28
|
+
accessibility_score: float = 0.0
|
|
29
|
+
equity_tier: str = "Undetermined"
|
|
30
|
+
percentile_rank: float = 0.0
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"zone_id": self.zone_id,
|
|
35
|
+
"name": self.name,
|
|
36
|
+
"lon": self.lon,
|
|
37
|
+
"lat": self.lat,
|
|
38
|
+
"population": self.population,
|
|
39
|
+
"accessibility_score": round(self.accessibility_score, 4),
|
|
40
|
+
"equity_tier": self.equity_tier,
|
|
41
|
+
"percentile_rank": round(self.percentile_rank, 1),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SupplyFacility:
|
|
47
|
+
"""Public service destination / facility (e.g. hospital, metro station, school)."""
|
|
48
|
+
|
|
49
|
+
facility_id: str
|
|
50
|
+
name: str
|
|
51
|
+
lon: float
|
|
52
|
+
lat: float
|
|
53
|
+
capacity: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class EquityScorecardResult:
|
|
58
|
+
"""Comprehensive Spatial Equity & Transport Justice Analytical Report."""
|
|
59
|
+
|
|
60
|
+
gini_coefficient: float
|
|
61
|
+
palma_ratio: float # Top 10% / Bottom 40% accessibility ratio
|
|
62
|
+
theil_index: float
|
|
63
|
+
mean_accessibility: float
|
|
64
|
+
median_accessibility: float
|
|
65
|
+
lorenz_curve: List[Dict[str, float]]
|
|
66
|
+
zones: List[ZoneAccessibilityRecord]
|
|
67
|
+
tier_distribution: Dict[str, int] = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"gini_coefficient": round(self.gini_coefficient, 4),
|
|
72
|
+
"palma_ratio": round(self.palma_ratio, 2),
|
|
73
|
+
"theil_index": round(self.theil_index, 4),
|
|
74
|
+
"mean_accessibility": round(self.mean_accessibility, 4),
|
|
75
|
+
"median_accessibility": round(self.median_accessibility, 4),
|
|
76
|
+
"tier_distribution": self.tier_distribution,
|
|
77
|
+
"lorenz_curve": self.lorenz_curve,
|
|
78
|
+
"zone_count": len(self.zones),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AccessibilityEquityEngine:
|
|
83
|
+
"""Spatial accessibility computation and distributional justice scorecard."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, catchment_radius_m: float = 1500.0) -> None:
|
|
86
|
+
self.catchment_radius_m = catchment_radius_m
|
|
87
|
+
|
|
88
|
+
def gaussian_decay(self, distance_m: float) -> float:
|
|
89
|
+
"""Continuous Gaussian distance friction function."""
|
|
90
|
+
if distance_m > self.catchment_radius_m:
|
|
91
|
+
return 0.0
|
|
92
|
+
d_0 = self.catchment_radius_m
|
|
93
|
+
numerator = math.exp(-0.5 * ((distance_m / d_0) ** 2)) - math.exp(-0.5)
|
|
94
|
+
denominator = 1.0 - math.exp(-0.5)
|
|
95
|
+
return max(0.0, numerator / denominator)
|
|
96
|
+
|
|
97
|
+
def compute_e2sfca(
|
|
98
|
+
self,
|
|
99
|
+
demand_zones: Sequence[ZoneAccessibilityRecord],
|
|
100
|
+
supply_facilities: Sequence[SupplyFacility],
|
|
101
|
+
distance_func: Optional[Callable[[Tuple[float, float], Tuple[float, float]], float]] = None,
|
|
102
|
+
) -> EquityScorecardResult:
|
|
103
|
+
"""Execute Enhanced Two-Step Floating Catchment Area (E2SFCA) algorithm."""
|
|
104
|
+
from .kinematics import haversine_distance_2d
|
|
105
|
+
|
|
106
|
+
dist_fn = distance_func or (lambda p1, p2: haversine_distance_2d(p1, p2))
|
|
107
|
+
zones_list = [
|
|
108
|
+
ZoneAccessibilityRecord(
|
|
109
|
+
zone_id=z.zone_id,
|
|
110
|
+
name=z.name,
|
|
111
|
+
lon=z.lon,
|
|
112
|
+
lat=z.lat,
|
|
113
|
+
population=z.population,
|
|
114
|
+
)
|
|
115
|
+
for z in demand_zones
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
# Step 1: Compute Provider-to-Population Ratio (R_j)
|
|
119
|
+
facility_ratios: Dict[str, float] = {}
|
|
120
|
+
for fac in supply_facilities:
|
|
121
|
+
weighted_demand = 0.0
|
|
122
|
+
for z in zones_list:
|
|
123
|
+
d = dist_fn((fac.lon, fac.lat), (z.lon, z.lat))
|
|
124
|
+
if d <= self.catchment_radius_m:
|
|
125
|
+
w = self.gaussian_decay(d)
|
|
126
|
+
weighted_demand += z.population * w
|
|
127
|
+
|
|
128
|
+
if weighted_demand > 0:
|
|
129
|
+
facility_ratios[fac.facility_id] = fac.capacity / weighted_demand
|
|
130
|
+
else:
|
|
131
|
+
facility_ratios[fac.facility_id] = 0.0
|
|
132
|
+
|
|
133
|
+
# Step 2: Compute Accessibility Score (A_i) for each Demand Zone
|
|
134
|
+
for z in zones_list:
|
|
135
|
+
acc_score = 0.0
|
|
136
|
+
for fac in supply_facilities:
|
|
137
|
+
d = dist_fn((z.lon, z.lat), (fac.lon, fac.lat))
|
|
138
|
+
if d <= self.catchment_radius_m:
|
|
139
|
+
w = self.gaussian_decay(d)
|
|
140
|
+
acc_score += facility_ratios[fac.facility_id] * w
|
|
141
|
+
z.accessibility_score = acc_score
|
|
142
|
+
|
|
143
|
+
# Step 3: Equity Diagnostics (Gini, Lorenz Curve, Theil Index)
|
|
144
|
+
sorted_zones = sorted(zones_list, key=lambda x: x.accessibility_score)
|
|
145
|
+
total_pop = sum(z.population for z in sorted_zones) or 1.0
|
|
146
|
+
total_acc_weighted = sum(z.population * z.accessibility_score for z in sorted_zones) or 1.0
|
|
147
|
+
|
|
148
|
+
cum_pop = 0.0
|
|
149
|
+
cum_acc = 0.0
|
|
150
|
+
lorenz_points: List[Dict[str, float]] = [{"pop_share": 0.0, "acc_share": 0.0}]
|
|
151
|
+
gini_sum = 0.0
|
|
152
|
+
prev_p = 0.0
|
|
153
|
+
prev_a = 0.0
|
|
154
|
+
|
|
155
|
+
for idx, z in enumerate(sorted_zones):
|
|
156
|
+
z.percentile_rank = (idx + 1) / len(sorted_zones) * 100.0
|
|
157
|
+
cum_pop += z.population
|
|
158
|
+
cum_acc += z.population * z.accessibility_score
|
|
159
|
+
|
|
160
|
+
p_curr = cum_pop / total_pop
|
|
161
|
+
a_curr = cum_acc / total_acc_weighted
|
|
162
|
+
|
|
163
|
+
gini_sum += (p_curr - prev_p) * (a_curr + prev_a)
|
|
164
|
+
prev_p = p_curr
|
|
165
|
+
prev_a = a_curr
|
|
166
|
+
|
|
167
|
+
if idx % max(1, len(sorted_zones) // 20) == 0 or idx == len(sorted_zones) - 1:
|
|
168
|
+
lorenz_points.append({"pop_share": round(p_curr, 4), "acc_share": round(a_curr, 4)})
|
|
169
|
+
|
|
170
|
+
gini = max(0.0, min(1.0, 1.0 - gini_sum))
|
|
171
|
+
|
|
172
|
+
# Theil Index
|
|
173
|
+
mean_acc = total_acc_weighted / total_pop
|
|
174
|
+
theil = 0.0
|
|
175
|
+
for z in sorted_zones:
|
|
176
|
+
if z.accessibility_score > 0 and mean_acc > 0:
|
|
177
|
+
ratio = z.accessibility_score / mean_acc
|
|
178
|
+
theil += (z.population / total_pop) * ratio * math.log(ratio)
|
|
179
|
+
theil = max(0.0, theil)
|
|
180
|
+
|
|
181
|
+
# Palma Ratio
|
|
182
|
+
bottom_40_acc = sum(
|
|
183
|
+
z.accessibility_score * z.population for z in sorted_zones if z.percentile_rank <= 40.0
|
|
184
|
+
)
|
|
185
|
+
top_10_acc = sum(
|
|
186
|
+
z.accessibility_score * z.population for z in sorted_zones if z.percentile_rank >= 90.0
|
|
187
|
+
)
|
|
188
|
+
palma = (top_10_acc / max(0.001, bottom_40_acc)) if bottom_40_acc > 0 else 1.0
|
|
189
|
+
|
|
190
|
+
# Tier Categorization
|
|
191
|
+
tier_counts = {"Transit Desert": 0, "Underserved": 0, "Adequate": 0, "Mobility Oasis": 0}
|
|
192
|
+
median_acc = sorted_zones[len(sorted_zones) // 2].accessibility_score
|
|
193
|
+
|
|
194
|
+
for z in sorted_zones:
|
|
195
|
+
if z.accessibility_score <= 0.0001:
|
|
196
|
+
z.equity_tier = "Transit Desert"
|
|
197
|
+
elif z.accessibility_score < median_acc * 0.5:
|
|
198
|
+
z.equity_tier = "Underserved"
|
|
199
|
+
elif z.accessibility_score < median_acc * 1.5:
|
|
200
|
+
z.equity_tier = "Adequate"
|
|
201
|
+
else:
|
|
202
|
+
z.equity_tier = "Mobility Oasis"
|
|
203
|
+
tier_counts[z.equity_tier] += 1
|
|
204
|
+
|
|
205
|
+
return EquityScorecardResult(
|
|
206
|
+
gini_coefficient=gini,
|
|
207
|
+
palma_ratio=palma,
|
|
208
|
+
theil_index=theil,
|
|
209
|
+
mean_accessibility=mean_acc,
|
|
210
|
+
median_accessibility=median_acc,
|
|
211
|
+
lorenz_curve=lorenz_points,
|
|
212
|
+
zones=sorted_zones,
|
|
213
|
+
tier_distribution=tier_counts,
|
|
214
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Analytic Hierarchy Process (AHP) multi-criteria decision engine.
|
|
2
|
+
|
|
3
|
+
Provides pairwise comparison matrix construction, principal eigenvector weight
|
|
4
|
+
calculation, and Saaty consistency ratio (CR < 0.10) validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Dict, List, Sequence
|
|
11
|
+
|
|
12
|
+
# Saaty Random Consistency Index (RI) table for matrix sizes 1..10
|
|
13
|
+
RANDOM_INDEX: Dict[int, float] = {
|
|
14
|
+
1: 0.00,
|
|
15
|
+
2: 0.00,
|
|
16
|
+
3: 0.58,
|
|
17
|
+
4: 0.90,
|
|
18
|
+
5: 1.12,
|
|
19
|
+
6: 1.24,
|
|
20
|
+
7: 1.32,
|
|
21
|
+
8: 1.41,
|
|
22
|
+
9: 1.45,
|
|
23
|
+
10: 1.49,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AHPResult:
|
|
29
|
+
"""Output of AHP analysis with weights, consistency metrics, and status."""
|
|
30
|
+
|
|
31
|
+
criteria: List[str]
|
|
32
|
+
weights: Dict[str, float]
|
|
33
|
+
lambda_max: float
|
|
34
|
+
consistency_index: float
|
|
35
|
+
consistency_ratio: float
|
|
36
|
+
is_consistent: bool
|
|
37
|
+
status_message: str
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"criteria": self.criteria,
|
|
42
|
+
"weights": {k: round(v, 4) for k, v in self.weights.items()},
|
|
43
|
+
"lambda_max": round(self.lambda_max, 4),
|
|
44
|
+
"consistency_index": round(self.consistency_index, 4),
|
|
45
|
+
"consistency_ratio": round(self.consistency_ratio, 4),
|
|
46
|
+
"is_consistent": self.is_consistent,
|
|
47
|
+
"status_message": self.status_message,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AHPEngine:
|
|
52
|
+
"""Solves AHP pairwise comparison matrices for multi-criteria routing weights."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, criteria: Sequence[str]) -> None:
|
|
55
|
+
self.criteria = list(criteria)
|
|
56
|
+
self.n = len(self.criteria)
|
|
57
|
+
self.matrix: List[List[float]] = [[1.0] * self.n for _ in range(self.n)]
|
|
58
|
+
|
|
59
|
+
def set_pairwise_comparison(self, criterion_a: str, criterion_b: str, value: float) -> None:
|
|
60
|
+
"""Set pairwise importance of criterion A relative to B (1/9 to 9 scale)."""
|
|
61
|
+
if criterion_a not in self.criteria or criterion_b not in self.criteria:
|
|
62
|
+
return
|
|
63
|
+
i = self.criteria.index(criterion_a)
|
|
64
|
+
j = self.criteria.index(criterion_b)
|
|
65
|
+
val = max(1.0 / 9.0, min(9.0, float(value)))
|
|
66
|
+
self.matrix[i][j] = val
|
|
67
|
+
self.matrix[j][i] = 1.0 / val
|
|
68
|
+
|
|
69
|
+
def calculate(self) -> AHPResult:
|
|
70
|
+
"""Compute principal eigenvector weights and consistency ratio."""
|
|
71
|
+
if self.n == 0:
|
|
72
|
+
return AHPResult([], {}, 0.0, 0.0, 0.0, True, "Empty criteria.")
|
|
73
|
+
if self.n == 1:
|
|
74
|
+
return AHPResult(
|
|
75
|
+
self.criteria, {self.criteria[0]: 1.0}, 1.0, 0.0, 0.0, True, "Single criterion."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# 1. Normalize columns
|
|
79
|
+
col_sums = [sum(self.matrix[row][col] for row in range(self.n)) for col in range(self.n)]
|
|
80
|
+
norm_matrix = [
|
|
81
|
+
[self.matrix[row][col] / max(1e-9, col_sums[col]) for col in range(self.n)]
|
|
82
|
+
for row in range(self.n)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# 2. Row averages (eigenvector weights)
|
|
86
|
+
weights_list = [sum(norm_matrix[row]) / self.n for row in range(self.n)]
|
|
87
|
+
weights = {self.criteria[i]: weights_list[i] for i in range(self.n)}
|
|
88
|
+
|
|
89
|
+
# 3. Maximum Eigenvalue (lambda_max)
|
|
90
|
+
weighted_sum_vector = [
|
|
91
|
+
sum(self.matrix[row][col] * weights_list[col] for col in range(self.n))
|
|
92
|
+
for row in range(self.n)
|
|
93
|
+
]
|
|
94
|
+
ratios = [weighted_sum_vector[i] / max(1e-9, weights_list[i]) for i in range(self.n)]
|
|
95
|
+
lambda_max = sum(ratios) / self.n
|
|
96
|
+
|
|
97
|
+
# 4. Consistency Index (CI) and Consistency Ratio (CR)
|
|
98
|
+
ci = (lambda_max - self.n) / max(1, self.n - 1)
|
|
99
|
+
ri = RANDOM_INDEX.get(self.n, 1.49)
|
|
100
|
+
cr = (ci / ri) if ri > 0 else 0.0
|
|
101
|
+
|
|
102
|
+
is_consistent = cr <= 0.10 or self.n <= 2
|
|
103
|
+
msg = (
|
|
104
|
+
f"Consistent (CR = {cr:.3f} <= 0.10)"
|
|
105
|
+
if is_consistent
|
|
106
|
+
else f"Inconsistent (CR = {cr:.3f} > 0.10); consider revising pairwise judgements."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return AHPResult(
|
|
110
|
+
criteria=self.criteria,
|
|
111
|
+
weights=weights,
|
|
112
|
+
lambda_max=lambda_max,
|
|
113
|
+
consistency_index=ci,
|
|
114
|
+
consistency_ratio=cr,
|
|
115
|
+
is_consistent=is_consistent,
|
|
116
|
+
status_message=msg,
|
|
117
|
+
)
|