mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
446 lines
13 KiB
Dart
446 lines
13 KiB
Dart
import 'dart:convert';
|
|
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
typedef ElevationDataSource =
|
|
Future<List<double?>> Function(List<LatLng> points);
|
|
|
|
class LineOfSightSample {
|
|
final double distanceMeters;
|
|
final double terrainMeters;
|
|
final double lineHeightMeters;
|
|
final double refractedHeightMeters;
|
|
final double clearanceMeters;
|
|
|
|
const LineOfSightSample({
|
|
required this.distanceMeters,
|
|
required this.terrainMeters,
|
|
required this.lineHeightMeters,
|
|
required this.refractedHeightMeters,
|
|
required this.clearanceMeters,
|
|
});
|
|
}
|
|
|
|
class LineOfSightResult {
|
|
final bool hasData;
|
|
final bool isClear;
|
|
final double totalDistanceMeters;
|
|
final double maxObstructionMeters;
|
|
final double? firstObstructionDistanceMeters;
|
|
final List<LineOfSightSample> samples;
|
|
final String? errorMessage;
|
|
final double usedKFactor;
|
|
final double? frequencyMHz;
|
|
|
|
const LineOfSightResult({
|
|
required this.hasData,
|
|
required this.isClear,
|
|
required this.totalDistanceMeters,
|
|
required this.maxObstructionMeters,
|
|
required this.firstObstructionDistanceMeters,
|
|
required this.samples,
|
|
required this.usedKFactor,
|
|
this.frequencyMHz,
|
|
this.errorMessage,
|
|
});
|
|
|
|
const LineOfSightResult.error({
|
|
required this.totalDistanceMeters,
|
|
required this.errorMessage,
|
|
this.usedKFactor = 4.0 / 3.0,
|
|
this.frequencyMHz,
|
|
}) : hasData = false,
|
|
isClear = false,
|
|
maxObstructionMeters = 0,
|
|
firstObstructionDistanceMeters = null,
|
|
samples = const [];
|
|
}
|
|
|
|
class LineOfSightPathSegment {
|
|
final int index;
|
|
final LatLng start;
|
|
final LatLng end;
|
|
final LineOfSightResult result;
|
|
|
|
const LineOfSightPathSegment({
|
|
required this.index,
|
|
required this.start,
|
|
required this.end,
|
|
required this.result,
|
|
});
|
|
}
|
|
|
|
class LineOfSightPathResult {
|
|
final List<LineOfSightPathSegment> segments;
|
|
final int clearSegments;
|
|
final int blockedSegments;
|
|
final int unknownSegments;
|
|
|
|
const LineOfSightPathResult({
|
|
required this.segments,
|
|
required this.clearSegments,
|
|
required this.blockedSegments,
|
|
required this.unknownSegments,
|
|
});
|
|
}
|
|
|
|
class LineOfSightService {
|
|
static const String errorElevationUnavailable =
|
|
'los_error_elevation_unavailable';
|
|
static const String errorInvalidInput = 'los_error_invalid_input';
|
|
|
|
static const double _earthRadiusMeters = 6371000.0;
|
|
static const Distance _distance = Distance();
|
|
static const Duration _cacheTtl = Duration(hours: 24);
|
|
static const int _maxFetchAttempts = 4; // initial try + 3 retries
|
|
static const Duration _initialBackoff = Duration(milliseconds: 300);
|
|
static const double _baselineFrequencyMHz = 915.0;
|
|
static const double _baselineKFactor = 4.0 / 3.0;
|
|
|
|
static double get baselineFrequencyMHz => _baselineFrequencyMHz;
|
|
static double get baselineKFactor => _baselineKFactor;
|
|
|
|
final http.Client _httpClient;
|
|
final bool _ownsHttpClient;
|
|
final ElevationDataSource? _elevationDataSource;
|
|
final Map<String, _CachedElevation> _elevationCache = {};
|
|
|
|
LineOfSightService({
|
|
http.Client? httpClient,
|
|
ElevationDataSource? elevationDataSource,
|
|
}) : _httpClient = httpClient ?? http.Client(),
|
|
_ownsHttpClient = httpClient == null,
|
|
_elevationDataSource = elevationDataSource;
|
|
|
|
Future<LineOfSightPathResult> analyzePath(
|
|
List<LatLng> points, {
|
|
double startAntennaHeightMeters = 1.5,
|
|
double endAntennaHeightMeters = 1.5,
|
|
double? frequencyMHz,
|
|
double obstructionToleranceMeters = 0.0,
|
|
}) async {
|
|
if (points.length < 2) {
|
|
return const LineOfSightPathResult(
|
|
segments: [],
|
|
clearSegments: 0,
|
|
blockedSegments: 0,
|
|
unknownSegments: 0,
|
|
);
|
|
}
|
|
|
|
final segments = <LineOfSightPathSegment>[];
|
|
var clearSegments = 0;
|
|
var blockedSegments = 0;
|
|
var unknownSegments = 0;
|
|
|
|
final kFactor = _kFactorForFrequency(frequencyMHz);
|
|
for (int i = 0; i < points.length - 1; i++) {
|
|
final result = await analyzeLink(
|
|
points[i],
|
|
points[i + 1],
|
|
startAntennaHeightMeters: startAntennaHeightMeters,
|
|
endAntennaHeightMeters: endAntennaHeightMeters,
|
|
kFactor: kFactor,
|
|
frequencyMHz: frequencyMHz,
|
|
obstructionToleranceMeters: obstructionToleranceMeters,
|
|
);
|
|
segments.add(
|
|
LineOfSightPathSegment(
|
|
index: i,
|
|
start: points[i],
|
|
end: points[i + 1],
|
|
result: result,
|
|
),
|
|
);
|
|
|
|
if (!result.hasData) {
|
|
unknownSegments++;
|
|
} else if (result.isClear) {
|
|
clearSegments++;
|
|
} else {
|
|
blockedSegments++;
|
|
}
|
|
}
|
|
|
|
return LineOfSightPathResult(
|
|
segments: segments,
|
|
clearSegments: clearSegments,
|
|
blockedSegments: blockedSegments,
|
|
unknownSegments: unknownSegments,
|
|
);
|
|
}
|
|
|
|
Future<LineOfSightResult> analyzeLink(
|
|
LatLng start,
|
|
LatLng end, {
|
|
double startAntennaHeightMeters = 1.5,
|
|
double endAntennaHeightMeters = 1.5,
|
|
required double kFactor,
|
|
double? frequencyMHz,
|
|
double obstructionToleranceMeters = 0.0,
|
|
}) async {
|
|
final totalDistanceMeters = _distance.as(LengthUnit.Meter, start, end);
|
|
if (totalDistanceMeters <= 1) {
|
|
return LineOfSightResult(
|
|
hasData: true,
|
|
isClear: true,
|
|
totalDistanceMeters: totalDistanceMeters,
|
|
maxObstructionMeters: 0,
|
|
firstObstructionDistanceMeters: null,
|
|
samples: const [],
|
|
usedKFactor: kFactor,
|
|
frequencyMHz: frequencyMHz,
|
|
);
|
|
}
|
|
|
|
final samplePoints = _buildSamplePoints(start, end, totalDistanceMeters);
|
|
final elevations = await _getElevations(samplePoints);
|
|
|
|
if (elevations.any((e) => e == null)) {
|
|
return LineOfSightResult.error(
|
|
totalDistanceMeters: totalDistanceMeters,
|
|
errorMessage: errorElevationUnavailable,
|
|
usedKFactor: kFactor,
|
|
frequencyMHz: frequencyMHz,
|
|
);
|
|
}
|
|
|
|
return computeFromElevations(
|
|
points: samplePoints,
|
|
elevations: elevations.cast<double>(),
|
|
startAntennaHeightMeters: startAntennaHeightMeters,
|
|
endAntennaHeightMeters: endAntennaHeightMeters,
|
|
kFactor: kFactor,
|
|
frequencyMHz: frequencyMHz,
|
|
obstructionToleranceMeters: obstructionToleranceMeters,
|
|
);
|
|
}
|
|
|
|
static LineOfSightResult computeFromElevations({
|
|
required List<LatLng> points,
|
|
required List<double> elevations,
|
|
double startAntennaHeightMeters = 1.5,
|
|
double endAntennaHeightMeters = 1.5,
|
|
required double kFactor,
|
|
double? frequencyMHz,
|
|
double obstructionToleranceMeters = 0.0,
|
|
}) {
|
|
if (points.length < 2 || elevations.length != points.length) {
|
|
return LineOfSightResult.error(
|
|
totalDistanceMeters: 0,
|
|
errorMessage: errorInvalidInput,
|
|
usedKFactor: kFactor,
|
|
frequencyMHz: frequencyMHz,
|
|
);
|
|
}
|
|
|
|
final totalDistanceMeters = _distance.as(
|
|
LengthUnit.Meter,
|
|
points.first,
|
|
points.last,
|
|
);
|
|
final effectiveEarthRadius = _earthRadiusMeters * kFactor;
|
|
final startLineHeight = elevations.first + startAntennaHeightMeters;
|
|
final endLineHeight = elevations.last + endAntennaHeightMeters;
|
|
|
|
var maxObstructionMeters = 0.0;
|
|
double? firstObstructionDistanceMeters;
|
|
final samples = <LineOfSightSample>[];
|
|
var isClear = true;
|
|
|
|
for (int i = 0; i < points.length; i++) {
|
|
final fraction = points.length == 1 ? 0.0 : i / (points.length - 1);
|
|
final distanceFromStart = totalDistanceMeters * fraction;
|
|
final lineHeight =
|
|
startLineHeight + (endLineHeight - startLineHeight) * fraction;
|
|
|
|
final earthBulge =
|
|
(distanceFromStart * (totalDistanceMeters - distanceFromStart)) /
|
|
(2 * effectiveEarthRadius);
|
|
final terrainHeight = elevations[i] + earthBulge;
|
|
final clearance = lineHeight - terrainHeight;
|
|
final unrefBulge =
|
|
(distanceFromStart * (totalDistanceMeters - distanceFromStart)) /
|
|
(2 * _earthRadiusMeters);
|
|
final refractedHeight = lineHeight + (unrefBulge - earthBulge);
|
|
|
|
if (clearance < -obstructionToleranceMeters) {
|
|
isClear = false;
|
|
final obstruction = -clearance;
|
|
if (obstruction > maxObstructionMeters) {
|
|
maxObstructionMeters = obstruction;
|
|
}
|
|
firstObstructionDistanceMeters ??= distanceFromStart;
|
|
}
|
|
|
|
samples.add(
|
|
LineOfSightSample(
|
|
distanceMeters: distanceFromStart,
|
|
terrainMeters: terrainHeight,
|
|
lineHeightMeters: lineHeight,
|
|
refractedHeightMeters: refractedHeight,
|
|
clearanceMeters: clearance,
|
|
),
|
|
);
|
|
}
|
|
|
|
return LineOfSightResult(
|
|
hasData: true,
|
|
isClear: isClear,
|
|
totalDistanceMeters: totalDistanceMeters,
|
|
maxObstructionMeters: maxObstructionMeters,
|
|
firstObstructionDistanceMeters: firstObstructionDistanceMeters,
|
|
samples: samples,
|
|
usedKFactor: kFactor,
|
|
frequencyMHz: frequencyMHz,
|
|
);
|
|
}
|
|
|
|
static double _kFactorForFrequency(double? frequencyMHz) {
|
|
if (frequencyMHz == null) return _baselineKFactor;
|
|
final delta =
|
|
(frequencyMHz - _baselineFrequencyMHz) / _baselineFrequencyMHz;
|
|
final adjustment = delta * 0.15;
|
|
final scaled = _baselineKFactor * (1 + adjustment);
|
|
return scaled.clamp(1.1, 1.6).toDouble();
|
|
}
|
|
|
|
List<LatLng> _buildSamplePoints(
|
|
LatLng start,
|
|
LatLng end,
|
|
double distanceMeters,
|
|
) {
|
|
final sampleCount = distanceMeters < 2000
|
|
? 21
|
|
: distanceMeters < 10000
|
|
? 41
|
|
: 81;
|
|
|
|
final points = <LatLng>[];
|
|
for (int i = 0; i < sampleCount; i++) {
|
|
final t = i / (sampleCount - 1);
|
|
points.add(
|
|
LatLng(
|
|
start.latitude + (end.latitude - start.latitude) * t,
|
|
start.longitude + (end.longitude - start.longitude) * t,
|
|
),
|
|
);
|
|
}
|
|
return points;
|
|
}
|
|
|
|
Future<List<double?>> _getElevations(List<LatLng> points) async {
|
|
final dataSource = _elevationDataSource;
|
|
if (dataSource != null) {
|
|
return dataSource(points);
|
|
}
|
|
|
|
final uncached = <int, LatLng>{};
|
|
final values = List<double?>.filled(points.length, null);
|
|
for (int i = 0; i < points.length; i++) {
|
|
final key = _cacheKey(points[i]);
|
|
final cached = _readCachedValue(key);
|
|
if (cached != null) {
|
|
values[i] = cached;
|
|
} else {
|
|
uncached[i] = points[i];
|
|
}
|
|
}
|
|
|
|
if (uncached.isEmpty) return values;
|
|
|
|
final latCsv = uncached.values
|
|
.map((p) => p.latitude.toStringAsFixed(6))
|
|
.join(',');
|
|
final lonCsv = uncached.values
|
|
.map((p) => p.longitude.toStringAsFixed(6))
|
|
.join(',');
|
|
|
|
final uri = Uri.parse(
|
|
'https://api.open-meteo.com/v1/elevation?latitude=$latCsv&longitude=$lonCsv',
|
|
);
|
|
|
|
final response = await _getWithBackoff(uri);
|
|
if (response.statusCode != 200) {
|
|
return values;
|
|
}
|
|
|
|
final decoded = jsonDecode(response.body);
|
|
if (decoded is! Map<String, dynamic>) {
|
|
return values;
|
|
}
|
|
final elevations = decoded['elevation'];
|
|
if (elevations is! List) {
|
|
return values;
|
|
}
|
|
|
|
final indices = uncached.keys.toList();
|
|
for (int i = 0; i < min(indices.length, elevations.length); i++) {
|
|
final value = elevations[i];
|
|
if (value is! num) continue;
|
|
final index = indices[i];
|
|
final elevation = value.toDouble();
|
|
values[index] = elevation;
|
|
_elevationCache[_cacheKey(points[index])] = _CachedElevation(
|
|
value: elevation,
|
|
expiresAt: DateTime.now().add(_cacheTtl),
|
|
);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
Future<http.Response> _getWithBackoff(Uri uri) async {
|
|
var attempt = 0;
|
|
Duration backoff = _initialBackoff;
|
|
|
|
while (true) {
|
|
attempt++;
|
|
try {
|
|
final response = await _httpClient.get(uri);
|
|
if (!_shouldRetryStatus(response.statusCode) ||
|
|
attempt >= _maxFetchAttempts) {
|
|
return response;
|
|
}
|
|
} catch (_) {
|
|
if (attempt >= _maxFetchAttempts) rethrow;
|
|
}
|
|
|
|
await Future.delayed(backoff);
|
|
backoff *= 2;
|
|
}
|
|
}
|
|
|
|
bool _shouldRetryStatus(int statusCode) {
|
|
return statusCode == 429 || statusCode >= 500;
|
|
}
|
|
|
|
double? _readCachedValue(String key) {
|
|
final cached = _elevationCache[key];
|
|
if (cached == null) return null;
|
|
if (DateTime.now().isAfter(cached.expiresAt)) {
|
|
_elevationCache.remove(key);
|
|
return null;
|
|
}
|
|
return cached.value;
|
|
}
|
|
|
|
String _cacheKey(LatLng point) {
|
|
return '${point.latitude.toStringAsFixed(5)},${point.longitude.toStringAsFixed(5)}';
|
|
}
|
|
|
|
void dispose() {
|
|
if (_ownsHttpClient) {
|
|
_httpClient.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
class _CachedElevation {
|
|
final double value;
|
|
final DateTime expiresAt;
|
|
|
|
const _CachedElevation({required this.value, required this.expiresAt});
|
|
}
|