feat(tactical): integrate cross-country terrain slope, basalt rocks, and off-road mobility analysis into Isochrone engine

This commit is contained in:
Hamza-Ayed
2026-08-18 15:40:18 +03:00
parent 9088af0534
commit ad231c6f20
2 changed files with 219 additions and 19 deletions
+157 -18
View File
@@ -524,20 +524,25 @@ export class TacticalService {
this.logger.debug(`GraphHopper native isochrone probe unavailable (${err?.message}), using high-speed radial road network engine`);
}
// 2. High-Speed Radial Road Network Ray-Casting (24 Directional Azimuth Probes)
const numRays = 24;
// 2. High-Precision Hybrid Road-Network + Off-Road Terrain Passability Engine (32 Radial Azimuths)
const numRays = 32;
const maxSeconds = Math.max(...timeBuckets);
const maxDistMeters = (baseSpeedKmh * 1000 / 3600) * maxSeconds * profileSpeedFactor * 1.35;
interface RayResult {
interface RayAnalysis {
angle: number;
effectiveSpeed: number;
maxPathDist: number;
success: boolean;
roadPathDist: number;
roadPathTimeSec: number;
effectiveRoadSpeed: number;
offRoadSpeedMps: number;
avgSlopeDeg: number;
isCliffBarrier: boolean;
isBasalt: boolean;
hasRoad: boolean;
}
// Concurrently probe road networks across all 24 radial directions
const rayPromises: Promise<RayResult>[] = [];
// Concurrently probe road networks and compute terrain slope/passability across all 32 radial directions
const rayPromises: Promise<RayAnalysis>[] = [];
for (let i = 0; i < numRays; i++) {
const angle = (i * 2 * Math.PI) / numRays;
const dLat = (maxDistMeters / 6371000) * (180 / Math.PI) * Math.cos(angle);
@@ -547,8 +552,16 @@ export class TacticalService {
rayPromises.push(
(async () => {
// A. Compute Off-Road Terrain Passability along this exact radial bearing
const terrainInfo = this.computeOffRoadTerrainSpeed(lat, lng, angle, maxDistMeters, vehicleProfile);
// B. Probe Road Network Connectivity via Internal GraphHopper
let hasRoad = false;
let roadPathDist = 0;
let roadPathTimeSec = 0;
let effectiveRoadSpeed = (baseSpeedKmh / 3.6);
try {
// Lightweight internal route query: no instructions, no points array, no alternative routes
const routeRes = await axios.post(
`${this.graphHopperUrl}/route`,
{
@@ -563,30 +576,75 @@ export class TacticalService {
if (routeRes.data && routeRes.data.paths && routeRes.data.paths.length > 0) {
const path = routeRes.data.paths[0];
const pathDistance = path.distance; // meters
const pathTimeSec = (path.time / 1000) / profileSpeedFactor; // effective seconds
const effectiveSpeed = pathTimeSec > 0 ? (pathDistance / pathTimeSec) : (baseSpeedKmh / 3.6);
return { angle, effectiveSpeed, maxPathDist: pathDistance, success: true };
roadPathDist = path.distance; // meters
roadPathTimeSec = (path.time / 1000) / profileSpeedFactor; // effective seconds
if (roadPathTimeSec > 0 && roadPathDist > 50) {
hasRoad = true;
effectiveRoadSpeed = roadPathDist / roadPathTimeSec;
}
}
} catch (e) {
// Fallback for this individual angle
// Road not available in this direction
}
// Default road approximation along bearing
return { angle, effectiveSpeed: (baseSpeedKmh * 0.7) / 3.6, maxPathDist: maxDistMeters, success: false };
return {
angle,
roadPathDist,
roadPathTimeSec,
effectiveRoadSpeed,
offRoadSpeedMps: terrainInfo.speedMps,
avgSlopeDeg: terrainInfo.avgSlopeDeg,
isCliffBarrier: terrainInfo.isCliff,
isBasalt: terrainInfo.isBasalt,
hasRoad,
};
})()
);
}
const rayResults = await Promise.all(rayPromises);
// Compute Overall Terrain Passability & Soil Intelligence
const avgSlopeOverall = Math.round((rayResults.reduce((acc, r) => acc + r.avgSlopeDeg, 0) / numRays) * 10) / 10;
const cliffBarriersCount = rayResults.filter(r => r.isCliffBarrier).length;
const basaltCount = rayResults.filter(r => r.isBasalt).length;
const roadCoveragePct = Math.round((rayResults.filter(r => r.hasRoad).length / numRays) * 100);
const passabilityScore = Math.max(15, Math.min(99, Math.round(100 - avgSlopeOverall * 2.2 - cliffBarriersCount * 2)));
let mobilityConditionAr = 'طبيعة أرض سهلة ومستوية مع حركة سريعة';
if (cliffBarriersCount > 4 || avgSlopeOverall > 18) {
mobilityConditionAr = 'تضاريس جبلية وعرة جداً مع جروف صخرية وقواطع حادة تعيق الحركة خارج الطرق';
} else if (basaltCount > 4 || avgSlopeOverall > 10) {
mobilityConditionAr = 'طبيعة أرض صخرية/بازلتية متوسطة الوعورة تبطئ حركة الآليات خارج الطرق المعبدة';
} else if (avgSlopeOverall > 5) {
mobilityConditionAr = 'تلال متوسطة الانحدار مع إمكانية حركة جيدة لمركبات الدفع الرباعي';
}
const colors = ['#22c55e', '#eab308', '#ef4444', '#8b5cf6'];
const tiers = timeBuckets.map((seconds, idx) => {
const minutes = Math.round(seconds / 60);
const ringCoords: [number, number][] = [];
for (const ray of rayResults) {
// Distance reachable within 'seconds' along this road corridor
const reachableMeters = Math.min(ray.maxPathDist, ray.effectiveSpeed * seconds);
let reachableMeters = 0;
if (ray.hasRoad && ray.roadPathTimeSec > 0) {
if (seconds <= ray.roadPathTimeSec) {
// Reached distance along paved road network
reachableMeters = ray.effectiveRoadSpeed * seconds;
} else {
// Traversed the full road corridor, then continued cross-country off-road at terrain speed
const remainingSec = seconds - ray.roadPathTimeSec;
reachableMeters = ray.roadPathDist + (remainingSec * ray.offRoadSpeedMps);
}
} else {
// Off-road terrain movement only (no paved road in this bearing)
reachableMeters = seconds * ray.offRoadSpeedMps;
}
// Cap to maximum realistic boundary
reachableMeters = Math.min(reachableMeters, maxDistMeters * 1.2);
const dLat = (reachableMeters / 6371000) * (180 / Math.PI) * Math.cos(ray.angle);
const dLng = (reachableMeters / (6371000 * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(ray.angle);
@@ -627,6 +685,14 @@ export class TacticalService {
featureCollection: {
type: 'FeatureCollection',
features: tiers.map(t => t.polygon)
},
terrainPassability: {
avgSlopeDeg: avgSlopeOverall,
cliffBarriersCount,
basaltZoneDetected: basaltCount > 0,
roadCoveragePct,
passabilityScore,
mobilityConditionAr
}
};
@@ -634,6 +700,79 @@ export class TacticalService {
return result;
}
/**
* Compute Off-Road Cross-Country Terrain Mobility Speed along a radial azimuth
* حساب سرعة الحركة خارج الطرق المعبدة بالاعتماد على درجة الانحدار، طبيعة التربة، والصخور والبازلت
*/
private computeOffRoadTerrainSpeed(
lat: number,
lng: number,
angle: number,
maxDistMeters: number,
vehicleProfile: string
): { speedMps: number; avgSlopeDeg: number; isCliff: boolean; isBasalt: boolean } {
const samples = 5;
let totalSlopeDeg = 0;
let maxSlopeDeg = 0;
const stepDist = Math.min(maxDistMeters / samples, 1000);
let prevElev = this.estimateElevation(lat, lng);
for (let s = 1; s <= samples; s++) {
const curDist = s * stepDist;
const dLat = (curDist / 6371000) * (180 / Math.PI) * Math.cos(angle);
const dLng = (curDist / (6371000 * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI) * Math.sin(angle);
const curElev = this.estimateElevation(lat + dLat, lng + dLng);
const dz = Math.abs(curElev - prevElev);
const slopeRad = Math.atan(dz / stepDist);
const slopeDeg = (slopeRad * 180) / Math.PI;
totalSlopeDeg += slopeDeg;
if (slopeDeg > maxSlopeDeg) maxSlopeDeg = slopeDeg;
prevElev = curElev;
}
const avgSlopeDeg = Math.round((totalSlopeDeg / samples) * 10) / 10;
// Base off-road speeds by vehicle profile (km/h)
let baseOffRoadKmh = 22; // emergency 4x4
if (vehicleProfile === 'heavy') baseOffRoadKmh = 10; // heavy civil defense / fire engine
if (vehicleProfile === 'patrol') baseOffRoadKmh = 16; // security patrol 4x4
// Terrain slope & ruggedness penalty (Tobler Cross-Country Mobility Model)
// < 5°: Flat ground (100% speed)
// 5°-12°: Moderate slopes (65% speed)
// 12°-20°: Rugged slopes / Basalt terrain (30% speed)
// 20°-28°: Steep mountain ridges (10% speed)
// > 28°: Impassable cliffs / Rock walls (2% speed - vehicle barrier)
let terrainFactor = 1.0;
let isCliff = false;
if (avgSlopeDeg > 25 || maxSlopeDeg > 32) {
terrainFactor = 0.04;
isCliff = true;
} else if (avgSlopeDeg > 18 || maxSlopeDeg > 24) {
terrainFactor = 0.15;
isCliff = true;
} else if (avgSlopeDeg > 10) {
terrainFactor = 0.38;
} else if (avgSlopeDeg > 5) {
terrainFactor = 0.70;
}
// Basalt volcanic rock fields detection (North-Eastern Jordanian Harrah & Desert corridors)
const isBasalt = (lat >= 31.8 && lat <= 32.7 && lng >= 36.15 && lng <= 37.5);
if (isBasalt && terrainFactor > 0.3) {
terrainFactor *= 0.65; // Boulder obstruction & tire puncture risk
}
const effectiveOffRoadKmh = baseOffRoadKmh * terrainFactor;
return {
speedMps: effectiveOffRoadKmh / 3.6,
avgSlopeDeg,
isCliff,
isBasalt,
};
}
// --- Utility GIS Math ---
private calculatePolygonAreaKm2(coords: [number, number][]): number {
if (!coords || coords.length < 3) return 0;