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
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Biomechanical, vehicle, and microclimate kinematic models for 3D route analysis.
|
|
2
|
+
|
|
3
|
+
Includes:
|
|
4
|
+
- Geodesic 3D distance with elevation delta
|
|
5
|
+
- Tobler's Hiking Function for slope-aware pedestrian velocity
|
|
6
|
+
- Minetti (2002) metabolic energy expenditure equation
|
|
7
|
+
- Aerodynamic drag & wind resistance equations
|
|
8
|
+
- Surface rolling resistance coefficients (C_rr)
|
|
9
|
+
- Senior fatigue and metabolic degradation over distance
|
|
10
|
+
- Solar aspect and dynamic sun angle irradiance model
|
|
11
|
+
- UTCI-equivalent thermal comfort and heat stress formulas
|
|
12
|
+
- Cyclist climbing power and grade resistance curves
|
|
13
|
+
- E-Scooter traction and battery drain models
|
|
14
|
+
- Road hierarchy vehicle speed modeling
|
|
15
|
+
- AnimatedAvatar for multi-modal simulation interpolation
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import bisect
|
|
21
|
+
import math
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AnimatedAvatar:
|
|
28
|
+
"""State and precomputed kinematics for a single moving profile on canvas."""
|
|
29
|
+
|
|
30
|
+
profile_key: str
|
|
31
|
+
profile_name: str
|
|
32
|
+
color_hex: str
|
|
33
|
+
coordinates_3d: List[Tuple[float, float, float]]
|
|
34
|
+
cumulative_distances_m: List[float] = field(default_factory=list)
|
|
35
|
+
timestamps_s: List[float] = field(default_factory=list)
|
|
36
|
+
total_duration_s: float = 0.0
|
|
37
|
+
total_distance_m: float = 0.0
|
|
38
|
+
marker: Optional[Any] = None
|
|
39
|
+
|
|
40
|
+
def interpolate_position(self, target_time_s: float) -> Tuple[float, float, float]:
|
|
41
|
+
"""Interpolate (lon, lat, elevation) at given elapsed simulation time."""
|
|
42
|
+
if not self.coordinates_3d:
|
|
43
|
+
return 0.0, 0.0, 0.0
|
|
44
|
+
if (
|
|
45
|
+
not math.isfinite(target_time_s)
|
|
46
|
+
or target_time_s <= 0.0
|
|
47
|
+
or len(self.coordinates_3d) == 1
|
|
48
|
+
):
|
|
49
|
+
return self.coordinates_3d[0]
|
|
50
|
+
if target_time_s >= self.total_duration_s:
|
|
51
|
+
return self.coordinates_3d[-1]
|
|
52
|
+
|
|
53
|
+
if not self.timestamps_s or len(self.timestamps_s) < len(self.coordinates_3d):
|
|
54
|
+
return self.coordinates_3d[0]
|
|
55
|
+
|
|
56
|
+
# Binary search for interval
|
|
57
|
+
idx = bisect.bisect_right(self.timestamps_s, target_time_s)
|
|
58
|
+
if idx <= 0:
|
|
59
|
+
return self.coordinates_3d[0]
|
|
60
|
+
if idx >= len(self.coordinates_3d):
|
|
61
|
+
return self.coordinates_3d[-1]
|
|
62
|
+
|
|
63
|
+
t0 = self.timestamps_s[idx - 1]
|
|
64
|
+
t1 = self.timestamps_s[idx]
|
|
65
|
+
dt = t1 - t0
|
|
66
|
+
alpha = (target_time_s - t0) / dt if dt > 1e-6 else 0.0
|
|
67
|
+
alpha = max(0.0, min(1.0, alpha))
|
|
68
|
+
|
|
69
|
+
p0 = self.coordinates_3d[idx - 1]
|
|
70
|
+
p1 = self.coordinates_3d[idx]
|
|
71
|
+
|
|
72
|
+
lon = p0[0] + alpha * (p1[0] - p0[0])
|
|
73
|
+
lat = p0[1] + alpha * (p1[1] - p0[1])
|
|
74
|
+
ele = p0[2] + alpha * (p1[2] - p0[2])
|
|
75
|
+
return lon, lat, ele
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Surface rolling resistance coefficients (C_rr)
|
|
79
|
+
ROLLING_RESISTANCE: Dict[str, float] = {
|
|
80
|
+
"asphalt": 0.004,
|
|
81
|
+
"concrete": 0.005,
|
|
82
|
+
"paved": 0.006,
|
|
83
|
+
"paving_stones": 0.012,
|
|
84
|
+
"cobblestone": 0.018,
|
|
85
|
+
"gravel": 0.022,
|
|
86
|
+
"compacted": 0.015,
|
|
87
|
+
"dirt": 0.030,
|
|
88
|
+
"ground": 0.035,
|
|
89
|
+
"grass": 0.045,
|
|
90
|
+
"sand": 0.080,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def haversine_distance_2d(coord1: Sequence[float], coord2: Sequence[float]) -> float:
|
|
95
|
+
"""Calculate the geodesic 2D distance in meters between two (lon, lat) points."""
|
|
96
|
+
if not coord1 or not coord2 or len(coord1) < 2 or len(coord2) < 2:
|
|
97
|
+
return 0.0
|
|
98
|
+
|
|
99
|
+
lon1, lat1 = float(coord1[0]), float(coord1[1])
|
|
100
|
+
lon2, lat2 = float(coord2[0]), float(coord2[1])
|
|
101
|
+
|
|
102
|
+
if not (
|
|
103
|
+
math.isfinite(lon1) and math.isfinite(lat1) and math.isfinite(lon2) and math.isfinite(lat2)
|
|
104
|
+
):
|
|
105
|
+
return 0.0
|
|
106
|
+
|
|
107
|
+
if abs(lon1 - lon2) < 1e-11 and abs(lat1 - lat2) < 1e-11:
|
|
108
|
+
return 0.0
|
|
109
|
+
|
|
110
|
+
radius = 6371008.8 # WGS84 mean earth radius in meters
|
|
111
|
+
phi1 = math.radians(lat1)
|
|
112
|
+
phi2 = math.radians(lat2)
|
|
113
|
+
delta_phi = math.radians(lat2 - lat1)
|
|
114
|
+
delta_lambda = math.radians(lon2 - lon1)
|
|
115
|
+
|
|
116
|
+
a = (
|
|
117
|
+
math.sin(delta_phi / 2.0) ** 2
|
|
118
|
+
+ math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2
|
|
119
|
+
)
|
|
120
|
+
a = max(0.0, min(1.0, a))
|
|
121
|
+
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(max(0.0, 1.0 - a)))
|
|
122
|
+
return radius * c
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def haversine_distance_3d(coord1: Sequence[float], coord2: Sequence[float]) -> float:
|
|
126
|
+
"""Calculate the 3D distance in meters considering elevation (Z coordinate)."""
|
|
127
|
+
d_2d = haversine_distance_2d(coord1, coord2)
|
|
128
|
+
z1 = float(coord1[2]) if len(coord1) > 2 and math.isfinite(float(coord1[2])) else 0.0
|
|
129
|
+
z2 = float(coord2[2]) if len(coord2) > 2 and math.isfinite(float(coord2[2])) else 0.0
|
|
130
|
+
dz = z2 - z1
|
|
131
|
+
return math.hypot(d_2d, dz)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def tobler_walking_speed(slope_fraction: float, base_speed_kmh: float = 5.0) -> float:
|
|
135
|
+
"""Compute walking speed (km/h) as a function of slope (dh / dx) using Tobler's curve.
|
|
136
|
+
|
|
137
|
+
Standard Tobler formula: W = 6.0 * exp(-3.5 * |s + 0.05|) km/h where s is slope fraction.
|
|
138
|
+
Scaled relative to the specific profile's base walking speed.
|
|
139
|
+
"""
|
|
140
|
+
s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
|
|
141
|
+
s = max(-0.60, min(0.60, s_val))
|
|
142
|
+
b_spd = float(base_speed_kmh) if math.isfinite(base_speed_kmh) and base_speed_kmh > 0 else 5.0
|
|
143
|
+
scale = max(0.2, b_spd / 5.0)
|
|
144
|
+
speed = 6.0 * math.exp(-3.5 * abs(s + 0.05)) * scale
|
|
145
|
+
return max(0.5, min(8.0, speed))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def minetti_energy_cost(
|
|
149
|
+
slope_fraction: float,
|
|
150
|
+
mass_kg: float = 70.0,
|
|
151
|
+
distance_m: float = 100.0,
|
|
152
|
+
) -> Tuple[float, float]:
|
|
153
|
+
"""Calculate metabolic energy expenditure using Minetti (2002) polynomial.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Tuple of (energy_in_joules, energy_in_kilocalories).
|
|
157
|
+
"""
|
|
158
|
+
s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
|
|
159
|
+
s = max(-0.50, min(0.50, s_val))
|
|
160
|
+
m = float(mass_kg) if math.isfinite(mass_kg) and mass_kg > 0 else 70.0
|
|
161
|
+
d = float(distance_m) if math.isfinite(distance_m) and distance_m >= 0 else 0.0
|
|
162
|
+
|
|
163
|
+
cost_j_kg_m = 280.5 * (s**5) - 58.7 * (s**4) - 76.8 * (s**3) + 51.9 * (s**2) + 19.6 * s + 2.5
|
|
164
|
+
# Minetti's polynomial is fitted over -0.45 <= i <= +0.45; outside that range it
|
|
165
|
+
# turns non-physical. Clamp to the smallest cost the fit itself produces rather
|
|
166
|
+
# than to an invented floor.
|
|
167
|
+
cost_j_kg_m = max(0.6, cost_j_kg_m)
|
|
168
|
+
total_joules = cost_j_kg_m * m * d
|
|
169
|
+
total_kcal = total_joules / 4184.0
|
|
170
|
+
return total_joules, total_kcal
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def senior_fatigue_decay(distance_m: float, accumulated_climb_m: float) -> float:
|
|
174
|
+
"""Calculate velocity decay multiplier (1.0 down to 0.45) for elderly pedestrians due to fatigue."""
|
|
175
|
+
d = float(distance_m) if math.isfinite(distance_m) and distance_m >= 0 else 0.0
|
|
176
|
+
climb = (
|
|
177
|
+
float(accumulated_climb_m)
|
|
178
|
+
if math.isfinite(accumulated_climb_m) and accumulated_climb_m >= 0
|
|
179
|
+
else 0.0
|
|
180
|
+
)
|
|
181
|
+
dist_km = d / 1000.0
|
|
182
|
+
fatigue_score = (dist_km * 0.12) + (climb / 100.0 * 0.25)
|
|
183
|
+
decay = math.exp(-0.35 * fatigue_score)
|
|
184
|
+
return max(0.45, min(1.0, decay))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def aerodynamic_drag_power(
|
|
188
|
+
velocity_kmh: float,
|
|
189
|
+
frontal_area_m2: float = 0.55,
|
|
190
|
+
drag_coeff: float = 0.88,
|
|
191
|
+
air_density: float = 1.225,
|
|
192
|
+
) -> float:
|
|
193
|
+
"""Calculate aerodynamic power resistance in Watts (P_aero = 0.5 * rho * C_d * A * v^3)."""
|
|
194
|
+
v_kmh = float(velocity_kmh) if math.isfinite(velocity_kmh) and velocity_kmh >= 0 else 0.0
|
|
195
|
+
v_ms = max(0.0, v_kmh / 3.6)
|
|
196
|
+
return 0.5 * air_density * drag_coeff * frontal_area_m2 * (v_ms**3)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def rolling_resistance_force(
|
|
200
|
+
mass_kg: float,
|
|
201
|
+
slope_fraction: float,
|
|
202
|
+
surface_type: str = "asphalt",
|
|
203
|
+
) -> float:
|
|
204
|
+
"""Calculate rolling friction resistance force in Newtons (F_roll = C_rr * m * g * cos(theta))."""
|
|
205
|
+
m = float(mass_kg) if math.isfinite(mass_kg) and mass_kg > 0 else 70.0
|
|
206
|
+
s = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
|
|
207
|
+
c_rr = ROLLING_RESISTANCE.get(str(surface_type).lower(), 0.005)
|
|
208
|
+
theta = math.atan(abs(s))
|
|
209
|
+
g = 9.80665
|
|
210
|
+
return c_rr * m * g * math.cos(theta)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cycling_energy_cost(
|
|
214
|
+
slope_fraction: float,
|
|
215
|
+
mass_kg: float = 70.0,
|
|
216
|
+
distance_m: float = 0.0,
|
|
217
|
+
bike_mass_kg: float = 15.0,
|
|
218
|
+
surface: str = "asphalt",
|
|
219
|
+
gross_efficiency: float = 0.24,
|
|
220
|
+
) -> Tuple[float, float]:
|
|
221
|
+
"""Metabolic energy for cycling a segment, from a real power balance.
|
|
222
|
+
|
|
223
|
+
Mechanical work is the sum of the gravitational, rolling and aerodynamic terms
|
|
224
|
+
over the segment; metabolic energy is that work divided by cycling gross
|
|
225
|
+
efficiency (~0.20-0.25 in the literature). Descents recover no metabolic energy
|
|
226
|
+
but still cost basal effort, so the result is floored at zero work.
|
|
227
|
+
|
|
228
|
+
Returns (joules, kilocalories).
|
|
229
|
+
"""
|
|
230
|
+
s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
|
|
231
|
+
s = max(-0.30, min(0.30, s_val))
|
|
232
|
+
rider = float(mass_kg) if math.isfinite(mass_kg) and mass_kg > 0 else 70.0
|
|
233
|
+
bike = float(bike_mass_kg) if math.isfinite(bike_mass_kg) and bike_mass_kg > 0 else 15.0
|
|
234
|
+
d = float(distance_m) if math.isfinite(distance_m) and distance_m >= 0 else 0.0
|
|
235
|
+
eff = (
|
|
236
|
+
float(gross_efficiency)
|
|
237
|
+
if math.isfinite(gross_efficiency) and 0.05 < gross_efficiency < 1.0
|
|
238
|
+
else 0.24
|
|
239
|
+
)
|
|
240
|
+
total_mass = rider + bike
|
|
241
|
+
|
|
242
|
+
theta = math.atan(s)
|
|
243
|
+
g = 9.80665
|
|
244
|
+
|
|
245
|
+
f_grav = total_mass * g * math.sin(theta)
|
|
246
|
+
f_roll = rolling_resistance_force(total_mass, s, surface)
|
|
247
|
+
|
|
248
|
+
speed_kmh = cyclist_speed(s, total_mass_kg=total_mass, surface=surface)
|
|
249
|
+
p_aero = aerodynamic_drag_power(speed_kmh)
|
|
250
|
+
v_ms = max(0.1, speed_kmh / 3.6)
|
|
251
|
+
f_aero = p_aero / v_ms
|
|
252
|
+
|
|
253
|
+
f_total = f_grav + f_roll + f_aero
|
|
254
|
+
mechanical_j = max(0.0, f_total) * d
|
|
255
|
+
metabolic_j = mechanical_j / eff
|
|
256
|
+
return metabolic_j, metabolic_j / 4184.0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cyclist_speed(
|
|
260
|
+
slope_fraction: float,
|
|
261
|
+
base_speed_kmh: float = 18.0,
|
|
262
|
+
rider_power_watts: float = 160.0,
|
|
263
|
+
total_mass_kg: float = 85.0,
|
|
264
|
+
surface: str = "asphalt",
|
|
265
|
+
) -> float:
|
|
266
|
+
"""Calculate physics-based bicycle speed (km/h) accounting for gradient, rolling resistance, and drag."""
|
|
267
|
+
s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
|
|
268
|
+
s = max(-0.25, min(0.25, s_val))
|
|
269
|
+
b_spd = float(base_speed_kmh) if math.isfinite(base_speed_kmh) and base_speed_kmh > 0 else 18.0
|
|
270
|
+
p_watts = (
|
|
271
|
+
float(rider_power_watts)
|
|
272
|
+
if math.isfinite(rider_power_watts) and rider_power_watts > 0
|
|
273
|
+
else 160.0
|
|
274
|
+
)
|
|
275
|
+
m_kg = float(total_mass_kg) if math.isfinite(total_mass_kg) and total_mass_kg > 0 else 85.0
|
|
276
|
+
|
|
277
|
+
if s < -0.02:
|
|
278
|
+
# Downhill with coasting limit
|
|
279
|
+
speed = b_spd * (1.0 + abs(s) * 2.2)
|
|
280
|
+
return min(45.0, speed)
|
|
281
|
+
|
|
282
|
+
# Uphill power balance: P = (F_gravity + F_roll + F_aero) * v
|
|
283
|
+
g = 9.80665
|
|
284
|
+
f_grav = m_kg * g * math.sin(math.atan(s))
|
|
285
|
+
f_roll = rolling_resistance_force(m_kg, s, surface)
|
|
286
|
+
f_resist = max(1.0, f_grav + f_roll)
|
|
287
|
+
|
|
288
|
+
# Effective speed from mechanical power: v = P / F
|
|
289
|
+
v_ms = p_watts / f_resist
|
|
290
|
+
speed_kmh = v_ms * 3.6
|
|
291
|
+
return max(3.5, min(b_spd * 1.5, speed_kmh))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def scooter_speed(slope_fraction: float, base_speed_kmh: float = 20.0) -> float:
|
|
295
|
+
"""Calculate micromobility/e-scooter speed (km/h) with motor torque reduction on slopes."""
|
|
296
|
+
s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
|
|
297
|
+
s = max(-0.20, min(0.20, s_val))
|
|
298
|
+
b_spd = float(base_speed_kmh) if math.isfinite(base_speed_kmh) and base_speed_kmh > 0 else 20.0
|
|
299
|
+
|
|
300
|
+
if s > 0.12:
|
|
301
|
+
return 4.0 # grade exceeds motor climbing capability
|
|
302
|
+
if s > 0:
|
|
303
|
+
speed = b_spd * max(0.25, 1.0 - (s / 0.14) ** 1.5)
|
|
304
|
+
return max(4.0, speed)
|
|
305
|
+
return min(25.0, b_spd * (1.0 + abs(s) * 0.5))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def vehicle_free_flow_speed(
|
|
309
|
+
hierarchy_rank: int,
|
|
310
|
+
lanes: int = 2,
|
|
311
|
+
slope_pct: float = 0.0,
|
|
312
|
+
base_vehicle_speed_kmh: float = 50.0,
|
|
313
|
+
) -> float:
|
|
314
|
+
"""Modelled vehicle speed in km/h based on functional road classification, vehicle profile, lanes, and grade."""
|
|
315
|
+
base_speeds = {
|
|
316
|
+
1: 80.0, # Motorway / Primary Arterial
|
|
317
|
+
2: 60.0, # Secondary Arterial
|
|
318
|
+
3: 45.0, # Collector / Major Urban
|
|
319
|
+
4: 30.0, # Local Residential Street
|
|
320
|
+
5: 20.0, # Narrow Alley / Service Way
|
|
321
|
+
}
|
|
322
|
+
road_speed = base_speeds.get(hierarchy_rank, 30.0)
|
|
323
|
+
v_base = (
|
|
324
|
+
float(base_vehicle_speed_kmh)
|
|
325
|
+
if math.isfinite(base_vehicle_speed_kmh) and base_vehicle_speed_kmh > 0
|
|
326
|
+
else 50.0
|
|
327
|
+
)
|
|
328
|
+
speed = road_speed * (v_base / 50.0)
|
|
329
|
+
l_count = int(lanes) if math.isfinite(lanes) and lanes > 0 else 2
|
|
330
|
+
speed += min(10.0, max(0, l_count - 2) * 4.0)
|
|
331
|
+
s_pct = float(slope_pct) if math.isfinite(slope_pct) else 0.0
|
|
332
|
+
routed_slope = min(35.0, max(0.0, abs(s_pct)))
|
|
333
|
+
speed *= max(0.50, 1.0 - (routed_slope / 100.0) * 0.85)
|
|
334
|
+
return max(10.0, speed)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def solar_irradiance_aspect_factor(
|
|
338
|
+
aspect_deg: float,
|
|
339
|
+
slope_pct: float,
|
|
340
|
+
sun_azimuth_deg: float = 180.0, # Solar noon south
|
|
341
|
+
sun_elevation_deg: float = 55.0,
|
|
342
|
+
) -> float:
|
|
343
|
+
"""Calculate relative solar irradiance factor (0.0 to 1.0) based on slope angle and sun position."""
|
|
344
|
+
s_pct = float(slope_pct) if math.isfinite(slope_pct) else 0.0
|
|
345
|
+
asp_deg = float(aspect_deg) if math.isfinite(aspect_deg) else 180.0
|
|
346
|
+
sun_az = float(sun_azimuth_deg) if math.isfinite(sun_azimuth_deg) else 180.0
|
|
347
|
+
sun_el = float(sun_elevation_deg) if math.isfinite(sun_elevation_deg) else 55.0
|
|
348
|
+
|
|
349
|
+
slope_rad = math.atan(abs(s_pct) / 100.0)
|
|
350
|
+
aspect_rad = math.radians(asp_deg)
|
|
351
|
+
sun_az_rad = math.radians(sun_az)
|
|
352
|
+
sun_el_rad = math.radians(sun_el)
|
|
353
|
+
|
|
354
|
+
# Cosine of incidence angle
|
|
355
|
+
cos_inc = math.sin(sun_el_rad) * math.cos(slope_rad) + math.cos(sun_el_rad) * math.sin(
|
|
356
|
+
slope_rad
|
|
357
|
+
) * math.cos(sun_az_rad - aspect_rad)
|
|
358
|
+
if not math.isfinite(cos_inc):
|
|
359
|
+
return 0.5
|
|
360
|
+
return max(0.0, min(1.0, cos_inc))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def universal_thermal_comfort_utci(
|
|
364
|
+
temp_c: float,
|
|
365
|
+
relative_humidity_pct: float = 50.0,
|
|
366
|
+
wind_speed_ms: float = 1.0,
|
|
367
|
+
mean_radiant_temp_c: float = 35.0,
|
|
368
|
+
) -> float:
|
|
369
|
+
"""Approximation of Universal Thermal Climate Index (UTCI in Celsius).
|
|
370
|
+
|
|
371
|
+
Returns normalized thermal strain score (0.0 = comfortable/cool, 1.0 = severe heat stress).
|
|
372
|
+
"""
|
|
373
|
+
t_c = float(temp_c) if math.isfinite(temp_c) else 25.0
|
|
374
|
+
mrt = float(mean_radiant_temp_c) if math.isfinite(mean_radiant_temp_c) else 35.0
|
|
375
|
+
w_ms = float(wind_speed_ms) if math.isfinite(wind_speed_ms) and wind_speed_ms >= 0 else 1.0
|
|
376
|
+
|
|
377
|
+
# Simplified empirical UTCI offset
|
|
378
|
+
utci = t_c + 0.33 * (mrt - t_c) - 0.70 * math.sqrt(max(0.1, w_ms))
|
|
379
|
+
if not math.isfinite(utci):
|
|
380
|
+
return 0.5
|
|
381
|
+
# Normalize: 18C = 0.0 (no stress), 38C+ = 1.0 (extreme heat stress)
|
|
382
|
+
norm_stress = (utci - 18.0) / 20.0
|
|
383
|
+
return max(0.0, min(1.0, norm_stress))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Localhost HTTP server for serving 02Route 3D WebGL Studio to QWebEngineView."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import functools
|
|
7
|
+
import http.server
|
|
8
|
+
import mimetypes
|
|
9
|
+
import socket
|
|
10
|
+
import socketserver
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class QuietCorsHandler(http.server.SimpleHTTPRequestHandler):
|
|
17
|
+
"""Simple HTTP Request Handler with CORS headers and quiet logging."""
|
|
18
|
+
|
|
19
|
+
def log_message(self, fmt: str, *args: object) -> None:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
def end_headers(self) -> None:
|
|
23
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
24
|
+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
25
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
26
|
+
self.send_header("Cache-Control", "no-cache")
|
|
27
|
+
super().end_headers()
|
|
28
|
+
|
|
29
|
+
def do_OPTIONS(self) -> None: # noqa: N802
|
|
30
|
+
self.send_response(204)
|
|
31
|
+
self.end_headers()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReusableTcpServer(socketserver.ThreadingTCPServer):
|
|
35
|
+
allow_reuse_address = True
|
|
36
|
+
daemon_threads = True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _port_is_open(host: str, port: int) -> bool:
|
|
40
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
41
|
+
sock.settimeout(0.2)
|
|
42
|
+
return sock.connect_ex((host, port)) == 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Route3DLocalServer:
|
|
46
|
+
"""Lightweight 127.0.0.1 HTTP server for serving WebGL ES-modules without file:// CORS blocks."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self, web_root: Path, host: str = "127.0.0.1", start_port: int = 8920, end_port: int = 8950
|
|
50
|
+
) -> None:
|
|
51
|
+
self.web_root = Path(web_root).resolve()
|
|
52
|
+
if not self.web_root.is_dir():
|
|
53
|
+
raise FileNotFoundError(f"WebGL assets directory does not exist: {self.web_root}")
|
|
54
|
+
self.host = host
|
|
55
|
+
self.start_port = max(0, min(65535, int(start_port)))
|
|
56
|
+
self.end_port = max(self.start_port, min(65535, int(end_port)))
|
|
57
|
+
self.port: Optional[int] = None
|
|
58
|
+
self._httpd: Optional[ReusableTcpServer] = None
|
|
59
|
+
self._thread: Optional[threading.Thread] = None
|
|
60
|
+
|
|
61
|
+
mimetypes.add_type("application/javascript", ".js")
|
|
62
|
+
mimetypes.add_type("application/json", ".geojson")
|
|
63
|
+
mimetypes.add_type("text/css", ".css")
|
|
64
|
+
mimetypes.add_type("text/html", ".html")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_running(self) -> bool:
|
|
68
|
+
return self._httpd is not None and self._thread is not None and self._thread.is_alive()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def url(self) -> str:
|
|
72
|
+
if self.port is None:
|
|
73
|
+
return ""
|
|
74
|
+
return f"http://{self.host}:{self.port}/index.html"
|
|
75
|
+
|
|
76
|
+
def start(self) -> str:
|
|
77
|
+
if self.is_running:
|
|
78
|
+
return self.url
|
|
79
|
+
|
|
80
|
+
handler = functools.partial(QuietCorsHandler, directory=str(self.web_root))
|
|
81
|
+
for port in range(self.start_port, self.end_port + 1):
|
|
82
|
+
if _port_is_open(self.host, port):
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
self._httpd = ReusableTcpServer((self.host, port), handler)
|
|
86
|
+
self.port = port
|
|
87
|
+
break
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
if self._httpd is None:
|
|
92
|
+
# Fallback to random free port assigned by OS
|
|
93
|
+
self._httpd = ReusableTcpServer((self.host, 0), handler)
|
|
94
|
+
self.port = self._httpd.server_address[1]
|
|
95
|
+
|
|
96
|
+
self._thread = threading.Thread(
|
|
97
|
+
target=self._httpd.serve_forever, name="Route3DServer", daemon=True
|
|
98
|
+
)
|
|
99
|
+
self._thread.start()
|
|
100
|
+
return self.url
|
|
101
|
+
|
|
102
|
+
def stop(self) -> None:
|
|
103
|
+
if self._httpd is None:
|
|
104
|
+
return
|
|
105
|
+
httpd = self._httpd
|
|
106
|
+
thread = self._thread
|
|
107
|
+
self._httpd = None
|
|
108
|
+
self._thread = None
|
|
109
|
+
self.port = None
|
|
110
|
+
|
|
111
|
+
with contextlib.suppress(Exception):
|
|
112
|
+
httpd.shutdown()
|
|
113
|
+
with contextlib.suppress(Exception):
|
|
114
|
+
httpd.server_close()
|
|
115
|
+
if thread and thread.is_alive():
|
|
116
|
+
thread.join(timeout=2.0)
|