fix: address PR #296 code review feedback

- Clamp ML predictions between physics floor (raw airtime) and ceiling
  (worst-case formula) so model can never produce unsafe timeouts
- Replace hourOfDay feature with secondsSinceLastRx for network activity
- Remove unused _ContactStats.stdDev and dead model persistence code
- Debounce observation writes (2s) instead of writing on every delivery
- Skip recording observations when pathLength is null to avoid corrupting
  training data
- Add comment explaining global (not per-contact) RX time tracking
- Remove notifyListeners from retrain to avoid unnecessary widget rebuilds
- Run dart format
chore/offband-rebrand
zjs81 4 months ago
parent 2ee2358ecc
commit b336aedbc5

@ -168,6 +168,8 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = false; bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService; TimeoutPredictionService? _timeoutPredictionService;
// Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now(); DateTime _lastRxTime = DateTime.now();
bool _batteryRequested = false; bool _batteryRequested = false;
bool _awaitingSelfInfo = false; bool _awaitingSelfInfo = false;
@ -694,23 +696,28 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessageCallback: _updateMessage, updateMessageCallback: _updateMessage,
clearContactPathCallback: clearContactPath, clearContactPathCallback: clearContactPath,
setContactPathCallback: setContactPath, setContactPathCallback: setContactPath,
calculateTimeoutCallback: (pathLength, messageBytes, {String? contactKey}) => calculateTimeoutCallback:
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes, contactKey: contactKey), (pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
pathLength: pathLength,
messageBytes: messageBytes,
contactKey: contactKey,
),
getSelfPublicKeyCallback: () => _selfPublicKey, getSelfPublicKeyCallback: () => _selfPublicKey,
prepareContactOutboundTextCallback: prepareContactOutboundText, prepareContactOutboundTextCallback: prepareContactOutboundText,
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
debugLogService: _appDebugLogService, debugLogService: _appDebugLogService,
recordPathResultCallback: _recordPathResult, recordPathResultCallback: _recordPathResult,
onDeliveryObservedCallback: (contactKey, pathLength, messageBytes, tripTimeMs) { onDeliveryObservedCallback:
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; (contactKey, pathLength, messageBytes, tripTimeMs) {
_timeoutPredictionService?.recordObservation( final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
contactKey: contactKey, _timeoutPredictionService?.recordObservation(
pathLength: pathLength, contactKey: contactKey,
messageBytes: messageBytes, pathLength: pathLength,
tripTimeMs: tripTimeMs, messageBytes: messageBytes,
secondsSinceLastRx: secSinceRx, tripTimeMs: tripTimeMs,
); secondsSinceLastRx: secSinceRx,
}, );
},
); );
} }
@ -2890,14 +2897,54 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
/// Calculate timeout for a message based on radio settings and path length /// Estimate single-packet airtime in ms from radio settings, or a fallback.
/// Returns timeout in milliseconds, considering number of hops int _estimateAirtimeMs(int messageBytes) {
if (_currentFreqHz != null &&
_currentBwHz != null &&
_currentSf != null &&
_currentCr != null) {
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
return calculateLoRaAirtime(
payloadBytes: messageBytes,
spreadingFactor: _currentSf!,
bandwidthHz: _currentBwHz!,
codingRate: cr,
lowDataRateOptimize: _currentSf! >= 11,
);
}
return 50; // fallback: ~SF7/BW125 for 100 bytes
}
/// Physics-based worst-case timeout (ceiling).
int _physicsMaxTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
return 500 + (16 * airtime);
} else {
return 500 + ((airtime * 6 + 250) * (pathLength + 1));
}
}
/// Physics-based minimum timeout (floor): raw traversal time.
int _physicsMinTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
return airtime;
} else {
return airtime * (pathLength + 1);
}
}
/// Calculate timeout for a message based on radio settings and path length.
/// Returns timeout in milliseconds, considering number of hops.
int calculateTimeout({ int calculateTimeout({
required int pathLength, required int pathLength,
int messageBytes = 100, int messageBytes = 100,
String? contactKey, String? contactKey,
}) { }) {
// Try ML-based prediction first final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime);
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
// Try ML-based prediction, clamped between physics bounds
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds; final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
final mlTimeout = _timeoutPredictionService?.predictTimeout( final mlTimeout = _timeoutPredictionService?.predictTimeout(
contactKey: contactKey, contactKey: contactKey,
@ -2905,35 +2952,11 @@ class MeshCoreConnector extends ChangeNotifier {
messageBytes: messageBytes, messageBytes: messageBytes,
secondsSinceLastRx: secSinceRx, secondsSinceLastRx: secSinceRx,
); );
if (mlTimeout != null) return mlTimeout; if (mlTimeout != null) {
return mlTimeout.clamp(physicsMin, physicsMax);
// If we have radio settings, use them for accurate calculation
if (_currentFreqHz != null &&
_currentBwHz != null &&
_currentSf != null &&
_currentCr != null) {
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
return calculateMessageTimeout(
freqHz: _currentFreqHz!,
bwHz: _currentBwHz!,
sf: _currentSf!,
cr: cr,
pathLength: pathLength,
messageBytes: messageBytes,
);
} }
// Fallback: Conservative estimates based on typical settings return physicsMax;
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes
const estimatedAirtime = 50;
if (pathLength < 0) {
// Flood mode: Base delay + 16× airtime
return 500 + (16 * estimatedAirtime);
} else {
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1))
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
}
} }
void _handleContact(Uint8List frame, {bool isContact = true}) { void _handleContact(Uint8List frame, {bool isContact = true}) {

@ -74,14 +74,20 @@ class MessageRetryService extends ChangeNotifier {
required Function(Message) updateMessageCallback, required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback, Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback, Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes, {String? contactKey})? calculateTimeoutCallback, Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback, Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback, String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService, AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService, AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback, Function(String, PathSelection, bool, int?)? recordPathResultCallback,
Function(String contactKey, int pathLength, int messageBytes, int tripTimeMs)? Function(
onDeliveryObservedCallback, String contactKey,
int pathLength,
int messageBytes,
int tripTimeMs,
)?
onDeliveryObservedCallback,
}) { }) {
_sendMessageCallback = sendMessageCallback; _sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback; _addMessageCallback = addMessageCallback;
@ -750,10 +756,12 @@ class MessageRetryService extends ChangeNotifier {
true, true,
tripTimeMs, tripTimeMs,
); );
if (_onDeliveryObservedCallback != null && tripTimeMs > 0) { if (_onDeliveryObservedCallback != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
_onDeliveryObservedCallback!( _onDeliveryObservedCallback!(
contact.publicKeyHex, contact.publicKeyHex,
message.pathLength ?? 0, message.pathLength!,
message.text.length, message.text.length,
tripTimeMs, tripTimeMs,
); );

@ -8,7 +8,6 @@ class StorageService {
static const String _pendingMessagesKey = 'pending_messages'; static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords'; static const String _repeaterPasswordsKey = 'repeater_passwords';
static const String _deliveryObservationsKey = 'delivery_observations'; static const String _deliveryObservationsKey = 'delivery_observations';
static const String _timeoutModelKey = 'timeout_ml_model';
Future<void> savePathHistory( Future<void> savePathHistory(
String contactPubKeyHex, String contactPubKeyHex,
@ -143,10 +142,7 @@ class StorageService {
try { try {
final list = jsonDecode(jsonStr) as List; final list = jsonDecode(jsonStr) as List;
return list return list
.map( .map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
(e) =>
DeliveryObservation.fromJson(e as Map<String, dynamic>),
)
.toList(); .toList();
} catch (e) { } catch (e) {
return []; return [];
@ -157,19 +153,4 @@ class StorageService {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.remove(_deliveryObservationsKey); await prefs.remove(_deliveryObservationsKey);
} }
Future<void> saveTimeoutModel(String modelJson) async {
final prefs = PrefsManager.instance;
await prefs.setString(_timeoutModelKey, modelJson);
}
Future<String?> loadTimeoutModel() async {
final prefs = PrefsManager.instance;
return prefs.getString(_timeoutModelKey);
}
Future<void> clearTimeoutModel() async {
final prefs = PrefsManager.instance;
await prefs.remove(_timeoutModelKey);
}
} }

@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:ml_algo/ml_algo.dart'; import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart'; import 'package:ml_dataframe/ml_dataframe.dart';
@ -9,16 +8,13 @@ import 'storage_service.dart';
class _ContactStats { class _ContactStats {
int count = 0; int count = 0;
double _sum = 0; double _sum = 0;
double _sumSq = 0;
void add(double ms) { void add(double ms) {
count++; count++;
_sum += ms; _sum += ms;
_sumSq += ms * ms;
} }
double get mean => _sum / count; double get mean => _sum / count;
double get stdDev => sqrt((_sumSq / count) - (mean * mean));
} }
class TimeoutPredictionService extends ChangeNotifier { class TimeoutPredictionService extends ChangeNotifier {
@ -27,9 +23,10 @@ class TimeoutPredictionService extends ChangeNotifier {
static const int minObservations = 10; static const int minObservations = 10;
static const int maxObservations = 100; static const int maxObservations = 100;
static const int _retrainInterval = 5; static const int _retrainInterval = 5;
// 1.5x multiplier on raw prediction to account for variance in delivery
// times tight enough to improve on worst-case physics, loose enough
// to avoid premature timeouts from model noise.
static const double _safetyMargin = 1.5; static const double _safetyMargin = 1.5;
static const int _minTimeoutMs = 2000;
static const int _maxTimeoutMs = 120000;
static const int _minContactObservations = 10; static const int _minContactObservations = 10;
List<DeliveryObservation> _observations = []; List<DeliveryObservation> _observations = [];
@ -37,6 +34,7 @@ class TimeoutPredictionService extends ChangeNotifier {
List<String> _activeFeatures = []; List<String> _activeFeatures = [];
int _observationsSinceLastTrain = 0; int _observationsSinceLastTrain = 0;
final Map<String, _ContactStats> _contactStats = {}; final Map<String, _ContactStats> _contactStats = {};
Timer? _persistTimer;
TimeoutPredictionService(StorageService storage) : _storage = storage; TimeoutPredictionService(StorageService storage) : _storage = storage;
TimeoutPredictionService.noStorage() : _storage = null; TimeoutPredictionService.noStorage() : _storage = null;
@ -89,7 +87,10 @@ class TimeoutPredictionService extends ChangeNotifier {
_trainModel(); _trainModel();
} }
_storage?.saveDeliveryObservations(_observations); _persistTimer?.cancel();
_persistTimer = Timer(const Duration(seconds: 2), () {
_storage?.saveDeliveryObservations(_observations);
});
debugPrint( debugPrint(
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops ' 'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
'(${_observations.length} total)', '(${_observations.length} total)',
@ -123,7 +124,9 @@ class TimeoutPredictionService extends ChangeNotifier {
final prediction = _model!.predict(features); final prediction = _model!.predict(features);
final rawValue = prediction.rows.first.first; final rawValue = prediction.rows.first.first;
var predictedMs = (rawValue is double) ? rawValue : (rawValue as num).toDouble(); var predictedMs = (rawValue is double)
? rawValue
: (rawValue as num).toDouble();
debugPrint( debugPrint(
'TimeoutPrediction: raw prediction=$predictedMs for ' 'TimeoutPrediction: raw prediction=$predictedMs for '
@ -142,8 +145,8 @@ class TimeoutPredictionService extends ChangeNotifier {
} }
} }
final timeout = // Connector clamps this between physics min/max bounds
(predictedMs * _safetyMargin).ceil().clamp(_minTimeoutMs, _maxTimeoutMs); final timeout = (predictedMs * _safetyMargin).ceil();
debugPrint( debugPrint(
'TimeoutPrediction: ML timeout ${timeout}ms ' 'TimeoutPrediction: ML timeout ${timeout}ms '
'(raw: ${predictedMs.round()}ms, contact: $contactKey)', '(raw: ${predictedMs.round()}ms, contact: $contactKey)',
@ -174,7 +177,9 @@ class TimeoutPredictionService extends ChangeNotifier {
} }
if (_activeFeatures.isEmpty) { if (_activeFeatures.isEmpty) {
debugPrint('TimeoutPrediction: no features with variance, skipping training'); debugPrint(
'TimeoutPrediction: no features with variance, skipping training',
);
return; return;
} }
@ -190,25 +195,19 @@ class TimeoutPredictionService extends ChangeNotifier {
return row; return row;
}); });
final data = DataFrame( final data = DataFrame([header, ...rows], headerExists: true);
[header, ...rows],
headerExists: true,
);
_model = LinearRegressor(data, 'deliveryMs'); _model = LinearRegressor(data, 'deliveryMs');
_observationsSinceLastTrain = 0; _observationsSinceLastTrain = 0;
// Log training summary with sample predictions // Log training summary with sample predictions
final avgMs = _observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) / final avgMs =
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
_observations.length; _observations.length;
debugPrint( debugPrint(
'TimeoutPrediction: trained on ${_observations.length} observations ' 'TimeoutPrediction: trained on ${_observations.length} observations '
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)', '(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
); );
final modelJson = jsonEncode(_model!.toJson());
_storage?.saveTimeoutModel(modelJson);
notifyListeners();
} catch (e) { } catch (e) {
debugPrint('TimeoutPrediction: training failed: $e'); debugPrint('TimeoutPrediction: training failed: $e');
} }

@ -6,18 +6,22 @@ import 'package:ml_dataframe/ml_dataframe.dart';
void main() { void main() {
test('LinearRegressor basic sanity check', () { test('LinearRegressor basic sanity check', () {
// Simple: y = 2x + 100 // Simple: y = 2x + 100
final data = DataFrame([ final data = DataFrame(
[1.0, 102.0], [
[2.0, 104.0], [1.0, 102.0],
[3.0, 106.0], [2.0, 104.0],
[4.0, 108.0], [3.0, 106.0],
[5.0, 110.0], [4.0, 108.0],
[10.0, 120.0], [5.0, 110.0],
[20.0, 140.0], [10.0, 120.0],
[50.0, 200.0], [20.0, 140.0],
[0.0, 100.0], [50.0, 200.0],
[100.0, 300.0], [0.0, 100.0],
], headerExists: false, header: ['x', 'y']); [100.0, 300.0],
],
headerExists: false,
header: ['x', 'y'],
);
debugPrint('Training data columns: ${data.header}'); debugPrint('Training data columns: ${data.header}');
debugPrint('Training data rows: ${data.rows.length}'); debugPrint('Training data rows: ${data.rows.length}');
@ -25,7 +29,9 @@ void main() {
final model = LinearRegressor(data, 'y'); final model = LinearRegressor(data, 'y');
final testDf = DataFrame( final testDf = DataFrame(
[[25.0]], [
[25.0],
],
headerExists: false, headerExists: false,
header: ['x'], header: ['x'],
); );
@ -38,45 +44,63 @@ void main() {
test('LinearRegressor multi-feature with constant column produces zeros', () { test('LinearRegressor multi-feature with constant column produces zeros', () {
// isFlood=0 for all rows zero-variance column singular matrix // isFlood=0 for all rows zero-variance column singular matrix
final data = DataFrame([ final data = DataFrame(
[0.0, 50.0, 14.0, 0.0, 1900.0], [
[0.0, 80.0, 14.0, 0.0, 2200.0], [0.0, 50.0, 14.0, 0.0, 1900.0],
[2.0, 50.0, 14.0, 0.0, 5000.0], [0.0, 80.0, 14.0, 0.0, 2200.0],
[4.0, 50.0, 14.0, 0.0, 9500.0], [2.0, 50.0, 14.0, 0.0, 5000.0],
], headerExists: false, header: [ [4.0, 50.0, 14.0, 0.0, 9500.0],
'pathLength', 'messageBytes', 'hourOfDay', 'isFlood', 'deliveryMs', ],
]); headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs'); final model = LinearRegressor(data, 'deliveryMs');
final testDf = DataFrame( final testDf = DataFrame(
[[2.0, 50.0, 14.0, 0.0]], [
[2.0, 50.0, 14.0, 0.0],
],
headerExists: false, headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'], header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
); );
final pred = model.predict(testDf).rows.first.first; final pred = model.predict(testDf).rows.first.first;
debugPrint('With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)'); debugPrint(
'With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)',
);
}); });
test('LinearRegressor 2-feature works correctly', () { test('LinearRegressor 2-feature works correctly', () {
// Just pathLength + messageBytes deliveryMs // Just pathLength + messageBytes deliveryMs
final data = DataFrame([ final data = DataFrame(
[0.0, 50.0, 1900.0], [
[0.0, 80.0, 2200.0], [0.0, 50.0, 1900.0],
[2.0, 50.0, 5000.0], [0.0, 80.0, 2200.0],
[2.0, 80.0, 5500.0], [2.0, 50.0, 5000.0],
[4.0, 50.0, 9500.0], [2.0, 80.0, 5500.0],
[4.0, 80.0, 10000.0], [4.0, 50.0, 9500.0],
[0.0, 30.0, 1800.0], [4.0, 80.0, 10000.0],
[2.0, 30.0, 4800.0], [0.0, 30.0, 1800.0],
[4.0, 30.0, 9000.0], [2.0, 30.0, 4800.0],
[0.0, 60.0, 2000.0], [4.0, 30.0, 9000.0],
], headerExists: false, header: ['pathLength', 'messageBytes', 'deliveryMs']); [0.0, 60.0, 2000.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'deliveryMs'],
);
final model = LinearRegressor(data, 'deliveryMs'); final model = LinearRegressor(data, 'deliveryMs');
for (final hops in [0.0, 2.0, 4.0]) { for (final hops in [0.0, 2.0, 4.0]) {
final testDf = DataFrame( final testDf = DataFrame(
[[hops, 50.0]], [
[hops, 50.0],
],
headerExists: false, headerExists: false,
header: ['pathLength', 'messageBytes'], header: ['pathLength', 'messageBytes'],
); );
@ -87,20 +111,28 @@ void main() {
test('LinearRegressor multi-feature with variance in all columns', () { test('LinearRegressor multi-feature with variance in all columns', () {
// Mix flood and direct so isFlood has variance // Mix flood and direct so isFlood has variance
final data = DataFrame([ final data = DataFrame(
[0.0, 50.0, 14.0, 0.0, 1900.0], [
[0.0, 80.0, 10.0, 0.0, 2200.0], [0.0, 50.0, 14.0, 0.0, 1900.0],
[2.0, 50.0, 16.0, 0.0, 5000.0], [0.0, 80.0, 10.0, 0.0, 2200.0],
[2.0, 80.0, 20.0, 0.0, 5500.0], [2.0, 50.0, 16.0, 0.0, 5000.0],
[4.0, 50.0, 8.0, 0.0, 9500.0], [2.0, 80.0, 20.0, 0.0, 5500.0],
[4.0, 80.0, 12.0, 0.0, 10000.0], [4.0, 50.0, 8.0, 0.0, 9500.0],
[-1.0, 40.0, 14.0, 1.0, 5000.0], [4.0, 80.0, 12.0, 0.0, 10000.0],
[-1.0, 60.0, 18.0, 1.0, 6500.0], [-1.0, 40.0, 14.0, 1.0, 5000.0],
[-1.0, 30.0, 10.0, 1.0, 4000.0], [-1.0, 60.0, 18.0, 1.0, 6500.0],
[-1.0, 80.0, 22.0, 1.0, 7000.0], [-1.0, 30.0, 10.0, 1.0, 4000.0],
], headerExists: false, header: [ [-1.0, 80.0, 22.0, 1.0, 7000.0],
'pathLength', 'messageBytes', 'hourOfDay', 'isFlood', 'deliveryMs', ],
]); headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs'); final model = LinearRegressor(data, 'deliveryMs');
@ -116,7 +148,9 @@ void main() {
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'], header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
); );
final pred = model.predict(testDf).rows.first.first; final pred = model.predict(testDf).rows.first.first;
debugPrint('4-feature: hops=${tc[0]} flood=${tc[3]}${(pred as num).round()}ms'); debugPrint(
'4-feature: hops=${tc[0]} flood=${tc[3]}${(pred as num).round()}ms',
);
} }
}); });
} }

@ -64,9 +64,9 @@ void main() {
expect(direct4!, greaterThan(direct2!)); expect(direct4!, greaterThan(direct2!));
expect(direct2, greaterThan(direct0!)); expect(direct2, greaterThan(direct0!));
// All should be within the clamp range // All should be positive
expect(direct0, greaterThanOrEqualTo(2000)); expect(direct0, greaterThan(0));
expect(direct4, lessThanOrEqualTo(120000)); expect(direct4, greaterThan(0));
// Print predictions for visibility // Print predictions for visibility
debugPrint('Predictions (with 1.5x safety margin):'); debugPrint('Predictions (with 1.5x safety margin):');

Loading…
Cancel
Save

Powered by TurnKey Linux.