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.
Files changed (42) hide show
  1. zero2route3d/__init__.py +212 -0
  2. zero2route3d/accessibility_equity.py +214 -0
  3. zero2route3d/ahp_engine.py +117 -0
  4. zero2route3d/api.py +244 -0
  5. zero2route3d/basemap.py +44 -0
  6. zero2route3d/cli.py +251 -0
  7. zero2route3d/copernicus_dem.py +76 -0
  8. zero2route3d/copernicus_eo_suite.py +443 -0
  9. zero2route3d/dem_fetcher.py +223 -0
  10. zero2route3d/environmental_raster.py +411 -0
  11. zero2route3d/evacuation.py +121 -0
  12. zero2route3d/html_bundler.py +603 -0
  13. zero2route3d/input_validation.py +100 -0
  14. zero2route3d/integrations.py +260 -0
  15. zero2route3d/isochrone_engine.py +220 -0
  16. zero2route3d/kinematics.py +383 -0
  17. zero2route3d/local_server.py +116 -0
  18. zero2route3d/map_matching_3d.py +374 -0
  19. zero2route3d/micro_elevation.py +264 -0
  20. zero2route3d/mobility_profiles.py +517 -0
  21. zero2route3d/multimodal.py +172 -0
  22. zero2route3d/network_source.py +512 -0
  23. zero2route3d/osm_downloader.py +328 -0
  24. zero2route3d/osm_styling.py +351 -0
  25. zero2route3d/pareto_router.py +331 -0
  26. zero2route3d/plotting.py +292 -0
  27. zero2route3d/profile_dxf.py +231 -0
  28. zero2route3d/profile_stats.py +648 -0
  29. zero2route3d/py.typed +1 -0
  30. zero2route3d/qml_generator.py +189 -0
  31. zero2route3d/raster_source.py +206 -0
  32. zero2route3d/report_generator.py +154 -0
  33. zero2route3d/route_corridor_3d.py +479 -0
  34. zero2route3d/routing_engine.py +747 -0
  35. zero2route3d/solar_shadow.py +157 -0
  36. zero2route3d/tsp_solver.py +78 -0
  37. zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
  38. zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
  39. zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
  40. zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
  41. zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
  42. zero2route3d_sdk-0.2.0.dist-info/top_level.txt +1 -0
@@ -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
+ )