Document LOS frequency and k-factor math

Show the connector frequency right next to the Frequency label, display the derived k value, and keep the info dialog tied to the exact
This commit is contained in:
just_stuff_tm 2026-02-23 02:42:58 -05:00
parent 2bdd9d35cc
commit fc55fb98ce
3 changed files with 166 additions and 35 deletions

View file

@ -14,6 +14,7 @@ import '../services/app_settings_service.dart';
import '../services/line_of_sight_service.dart';
import '../services/map_tile_cache_service.dart';
import '../utils/route_transitions.dart';
import '../connector/meshcore_connector.dart';
import '../widgets/app_bar.dart';
import '../widgets/quick_switch_bar.dart';
@ -110,10 +111,13 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
});
try {
final connector = context.read<MeshCoreConnector>();
final frequencyMHz = _normalizeFrequencyMHz(connector.currentFreqHz);
final result = await _lineOfSightService.analyzePath(
[start.point, end.point],
startAntennaHeightMeters: startAntenna,
endAntennaHeightMeters: endAntenna,
frequencyMHz: frequencyMHz,
);
if (!mounted) return;
if (!_isRunRequestCurrent(
@ -424,6 +428,12 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
Widget _buildControlPanel(bool isImperial) {
_sanitizeSelection();
final segment = _primarySegmentResult();
final connector = context.watch<MeshCoreConnector>();
final reportedFrequencyMHz = _normalizeFrequencyMHz(
connector.currentFreqHz,
);
final displayFrequencyMHz = segment?.frequencyMHz ?? reportedFrequencyMHz;
final kFactorUsed = segment?.usedKFactor;
final endpoints = _visibleEndpoints();
final distanceUnit = isImperial ? 'mi' : 'km';
final heightUnit = isImperial ? 'ft' : 'm';
@ -488,6 +498,52 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
),
),
const SizedBox(height: 4),
if (displayFrequencyMHz != null)
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 4),
child: Row(
children: [
Text(
'Frequency',
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
Text(
'${displayFrequencyMHz.toStringAsFixed(3)} MHz',
style: TextStyle(fontSize: 11, color: Colors.grey[700]),
),
if (kFactorUsed != null) ...[
const SizedBox(width: 8),
Text(
'k=${kFactorUsed.toStringAsFixed(3)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
),
),
const SizedBox(width: 4),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: const Icon(Icons.info_outline, size: 16),
color: Colors.grey[600],
tooltip: 'View calculation details',
onPressed: () {
_showFrequencyInfoDialog(
context,
displayFrequencyMHz,
kFactorUsed,
);
},
),
],
],
),
),
Text(
context.l10n.losElevationAttribution,
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
@ -896,6 +952,56 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
break;
}
}
void _showFrequencyInfoDialog(
BuildContext context,
double frequencyMHz,
double kFactor,
) {
final baselineFreq = LineOfSightService.baselineFrequencyMHz;
final baselineK = LineOfSightService.baselineKFactor;
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Radio horizon calculation'),
content: Text.rich(
TextSpan(
children: [
TextSpan(
text:
'Starting from k=$baselineK at ${baselineFreq.toStringAsFixed(3)} MHz, ',
),
const TextSpan(text: 'the calculation multiplies the offset by '),
TextSpan(
text:
'0.15 × (frequency ${baselineFreq.toStringAsFixed(3)}) / ${baselineFreq.toStringAsFixed(3)} ',
),
TextSpan(
text:
'to get k ≈ ${kFactor.toStringAsFixed(3)} for the current ${frequencyMHz.toStringAsFixed(3)} MHz band, ',
),
const TextSpan(
text: 'which defines the curved radio horizon cap.',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.common_ok),
),
],
),
);
}
double? _normalizeFrequencyMHz(int? frequencyHz) {
if (frequencyHz == null || frequencyHz <= 0) return null;
if (frequencyHz >= 1000000) return frequencyHz / 1e6;
if (frequencyHz >= 1000) return frequencyHz / 1e3;
return frequencyHz.toDouble();
}
}
class _LosProfilePainter extends CustomPainter {
@ -985,30 +1091,32 @@ class _LosProfilePainter extends CustomPainter {
..strokeWidth = 2,
);
final horizonLine = ui.Path();
const refractedLineColor = Color(0xFFFFD57F);
final refractedLine = ui.Path();
for (int i = 0; i < samples.length; i++) {
final p = mapPoint(
samples[i].distanceMeters,
samples[i].radioHorizonMeters,
samples[i].refractedHeightMeters,
);
if (i == 0) {
horizonLine.moveTo(p.dx, p.dy);
refractedLine.moveTo(p.dx, p.dy);
} else {
horizonLine.lineTo(p.dx, p.dy);
refractedLine.lineTo(p.dx, p.dy);
}
}
const horizonLineColor = Color(0xFF4BC0FF);
final horizonPaint = Paint()
..color = horizonLineColor
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawPath(horizonLine, horizonPaint);
canvas.drawPath(
refractedLine,
Paint()
..color = refractedLineColor
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
final capPath = ui.Path();
for (int i = 0; i < samples.length; i++) {
final p = mapPoint(
samples[i].distanceMeters,
samples[i].radioHorizonMeters,
samples[i].refractedHeightMeters,
);
if (i == 0) {
capPath.moveTo(p.dx, p.dy);
@ -1024,7 +1132,7 @@ class _LosProfilePainter extends CustomPainter {
capPath.lineTo(p.dx, p.dy);
}
capPath.close();
const horizonFillColor = Color(0x404BC0FF);
const horizonFillColor = Color(0x40FFD57F);
canvas.drawPath(
capPath,
Paint()
@ -1032,12 +1140,7 @@ class _LosProfilePainter extends CustomPainter {
..style = PaintingStyle.fill,
);
_drawLegend(
canvas,
horizonLineColor,
losLineColor,
terrainLineColor,
);
_drawLegend(canvas, refractedLineColor, losLineColor, terrainLineColor);
}
@override
@ -1090,15 +1193,13 @@ class _LosProfilePainter extends CustomPainter {
return painter;
}).toList();
final maxTextWidth = painters.map((p) => p.width).fold<double>(
0,
math.max,
);
final maxTextWidth = painters.map((p) => p.width).fold<double>(0, math.max);
final legendWidth =
legendPadding * 2 + swatchSize + swatchTextGap + maxTextWidth;
final legendHeight = legendPadding * 2 +
final legendHeight =
legendPadding * 2 +
entries.length * swatchSize +
(entries.length - 1) * entrySpacing;
@ -1125,10 +1226,7 @@ class _LosProfilePainter extends CustomPainter {
swatchSize,
swatchSize,
);
canvas.drawRect(
swatchRect,
Paint()..color = entry.color,
);
canvas.drawRect(swatchRect, Paint()..color = entry.color);
painter.paint(
canvas,

View file

@ -12,14 +12,14 @@ class LineOfSightSample {
final double distanceMeters;
final double terrainMeters;
final double lineHeightMeters;
final double radioHorizonMeters;
final double refractedHeightMeters;
final double clearanceMeters;
const LineOfSightSample({
required this.distanceMeters,
required this.terrainMeters,
required this.lineHeightMeters,
required this.radioHorizonMeters,
required this.refractedHeightMeters,
required this.clearanceMeters,
});
}
@ -32,6 +32,8 @@ class LineOfSightResult {
final double? firstObstructionDistanceMeters;
final List<LineOfSightSample> samples;
final String? errorMessage;
final double usedKFactor;
final double? frequencyMHz;
const LineOfSightResult({
required this.hasData,
@ -40,6 +42,8 @@ class LineOfSightResult {
required this.maxObstructionMeters,
required this.firstObstructionDistanceMeters,
required this.samples,
required this.usedKFactor,
this.frequencyMHz,
this.errorMessage,
});
@ -50,7 +54,9 @@ class LineOfSightResult {
isClear = false,
maxObstructionMeters = 0,
firstObstructionDistanceMeters = null,
samples = const [];
samples = const [],
usedKFactor = 4.0 / 3.0,
frequencyMHz = null;
}
class LineOfSightPathSegment {
@ -91,6 +97,11 @@ class LineOfSightService {
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;
@ -108,7 +119,7 @@ class LineOfSightService {
List<LatLng> points, {
double startAntennaHeightMeters = 1.5,
double endAntennaHeightMeters = 1.5,
double kFactor = 4.0 / 3.0,
double? frequencyMHz,
double obstructionToleranceMeters = 0.0,
}) async {
if (points.length < 2) {
@ -125,6 +136,7 @@ class LineOfSightService {
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],
@ -132,6 +144,7 @@ class LineOfSightService {
startAntennaHeightMeters: startAntennaHeightMeters,
endAntennaHeightMeters: endAntennaHeightMeters,
kFactor: kFactor,
frequencyMHz: frequencyMHz,
obstructionToleranceMeters: obstructionToleranceMeters,
);
segments.add(
@ -165,7 +178,8 @@ class LineOfSightService {
LatLng end, {
double startAntennaHeightMeters = 1.5,
double endAntennaHeightMeters = 1.5,
double kFactor = 4.0 / 3.0,
required double kFactor,
double? frequencyMHz,
double obstructionToleranceMeters = 0.0,
}) async {
final totalDistanceMeters = _distance.as(LengthUnit.Meter, start, end);
@ -177,6 +191,8 @@ class LineOfSightService {
maxObstructionMeters: 0,
firstObstructionDistanceMeters: null,
samples: const [],
usedKFactor: kFactor,
frequencyMHz: frequencyMHz,
);
}
@ -205,7 +221,8 @@ class LineOfSightService {
required List<double> elevations,
double startAntennaHeightMeters = 1.5,
double endAntennaHeightMeters = 1.5,
double kFactor = 4.0 / 3.0,
required double kFactor,
double? frequencyMHz,
double obstructionToleranceMeters = 0.0,
}) {
if (points.length < 2 || elevations.length != points.length) {
@ -240,7 +257,10 @@ class LineOfSightService {
(2 * effectiveEarthRadius);
final terrainHeight = elevations[i] + earthBulge;
final clearance = lineHeight - terrainHeight;
final radioHorizonHeight = lineHeight - earthBulge;
final unrefBulge =
(distanceFromStart * (totalDistanceMeters - distanceFromStart)) /
(2 * _earthRadiusMeters);
final refractedHeight = lineHeight + (unrefBulge - earthBulge);
if (clearance < -obstructionToleranceMeters) {
isClear = false;
@ -256,7 +276,7 @@ class LineOfSightService {
distanceMeters: distanceFromStart,
terrainMeters: terrainHeight,
lineHeightMeters: lineHeight,
radioHorizonMeters: radioHorizonHeight,
refractedHeightMeters: refractedHeight,
clearanceMeters: clearance,
),
);
@ -269,9 +289,20 @@ class LineOfSightService {
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,

View file

@ -16,6 +16,7 @@ void main() {
elevations: elevations,
startAntennaHeightMeters: 2,
endAntennaHeightMeters: 2,
kFactor: 4.0 / 3.0,
);
expect(result.hasData, isTrue);
@ -36,6 +37,7 @@ void main() {
elevations: elevations,
startAntennaHeightMeters: 1.5,
endAntennaHeightMeters: 1.5,
kFactor: 4.0 / 3.0,
);
expect(result.hasData, isTrue);