From e4cf46d75fa9a3531e0537a18033a1008d57e743 Mon Sep 17 00:00:00 2001 From: Speleobrad Date: Thu, 28 Aug 2025 23:01:33 +0100 Subject: [PATCH] Add survey quality scoring system with 5-star ratings Implements a comprehensive quality assessment system for cave survey data: Features: - 5-star rating system (1-5 stars) displayed below survey dates in section cards - Hover tooltips with detailed quality breakdown and top issues - Real-time scoring based on measurement accuracy and data completeness Scoring Metrics (weighted): - Measurement Consistency (50%): Evaluates heading/pitch in/out agreement - Length Validation (35%): Compares measured vs trigonometrically calculated lengths - Data Completeness (15%): Checks for missing values, bonus for LRUD/temperature Implementation: - SurveyQualityService: Core scoring algorithms with worst-case discrepancy penalties - SurveyQuality model: Quality data structure with formatted tooltip generation - StarRatingWidget: Visual 5-star display component with tooltips - Enhanced SectionCard: Integrated quality stars below survey timestamps Quality assessment helps surveyors identify measurement issues, problematic shots, and data reliability concerns for improved survey accuracy. --- lib/models/models.dart | 3 +- lib/models/survey_quality.dart | 140 +++++++++++ lib/services/survey_quality_service.dart | 292 +++++++++++++++++++++++ lib/widgets/sectioncard.dart | 18 +- lib/widgets/star_rating_widget.dart | 45 ++++ 5 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 lib/models/survey_quality.dart create mode 100644 lib/services/survey_quality_service.dart create mode 100644 lib/widgets/star_rating_widget.dart diff --git a/lib/models/models.dart b/lib/models/models.dart index 4050c79..f7b2b11 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -2,4 +2,5 @@ export 'enums.dart'; export 'shot.dart'; export 'section.dart'; -export 'section_list.dart'; \ No newline at end of file +export 'section_list.dart'; +export 'survey_quality.dart'; \ No newline at end of file diff --git a/lib/models/survey_quality.dart b/lib/models/survey_quality.dart new file mode 100644 index 0000000..f46a8f9 --- /dev/null +++ b/lib/models/survey_quality.dart @@ -0,0 +1,140 @@ + +/// Represents a quality issue found in survey data +class QualityIssue { + final String category; + final String description; + final String severity; // 'high', 'medium', 'low' + final int? shotIndex; // Optional shot index if issue is specific to a shot + + QualityIssue({ + required this.category, + required this.description, + required this.severity, + this.shotIndex, + }); +} + +/// Represents the overall quality assessment of a survey section +class SurveyQuality { + final int stars; // 1-5 star rating + final double score; // 0-100 numerical score + final List issues; + final Map subscores; + + SurveyQuality({ + required this.stars, + required this.score, + required this.issues, + required this.subscores, + }); + + /// Get a formatted tooltip message describing the quality score + String get tooltipMessage { + final buffer = StringBuffer(); + buffer.writeln('Survey Quality: $stars/5 stars (${score.toStringAsFixed(1)}/100)'); + buffer.writeln(); + + // Add subscore breakdown + subscores.forEach((metric, score) { + buffer.writeln('${_formatMetricName(metric)}: ${score.toStringAsFixed(1)}/100'); + }); + + if (issues.isNotEmpty) { + buffer.writeln(); + + // Group issues by category + final issuesByCategory = >{}; + for (final issue in issues) { + issuesByCategory.putIfAbsent(issue.category, () => []).add(issue); + } + + // Sort categories by impact (based on scoring weights) + final categoryOrder = ['Measurement Consistency', 'Length Validation', 'Data Completeness']; + + for (final category in categoryOrder) { + if (issuesByCategory.containsKey(category)) { + final categoryIssues = issuesByCategory[category]!; + + // Sort issues by degree of difference or percentage discrepancy (highest first) + categoryIssues.sort((a, b) { + final valueA = _extractNumericValue(a.description); + final valueB = _extractNumericValue(b.description); + + // Sort by numeric value descending (highest impact first) + if (valueA != null && valueB != null) { + return valueB.compareTo(valueA); + } + + // Fallback to severity sorting if no numeric values found + final severityOrder = {'high': 0, 'medium': 1, 'low': 2}; + final severityCompare = severityOrder[a.severity]!.compareTo(severityOrder[b.severity]!); + if (severityCompare != 0) return severityCompare; + return a.description.compareTo(b.description); + }); + + buffer.writeln('$category (${categoryIssues.length} issue${categoryIssues.length == 1 ? '' : 's'}):'); + + // Show top 3 issues from this category + final issuesToShow = categoryIssues.take(3); + for (final issue in issuesToShow) { + final severityIcon = _getSeverityIcon(issue.severity); + buffer.writeln(' $severityIcon ${issue.description}'); + } + + // Show remaining count if more than 3 issues + if (categoryIssues.length > 3) { + buffer.writeln(' • ... and ${categoryIssues.length - 3} more issue${categoryIssues.length - 3 == 1 ? '' : 's'}'); + } + + buffer.writeln(); + } + } + } + + return buffer.toString().trim(); + } + + String _getSeverityIcon(String severity) { + switch (severity) { + case 'high': + return '🔴'; + case 'medium': + return '🟡'; + case 'low': + return '🟢'; + default: + return '•'; + } + } + + /// Extract numeric value from issue description for sorting + double? _extractNumericValue(String description) { + // Look for patterns like "45.2°" or "23.4%" + final degreeMatch = RegExp(r'(\d+\.?\d*)°').firstMatch(description); + if (degreeMatch != null) { + return double.tryParse(degreeMatch.group(1)!); + } + + // Look for percentage patterns like "23.4%" + final percentMatch = RegExp(r'(\d+\.?\d*)%').firstMatch(description); + if (percentMatch != null) { + return double.tryParse(percentMatch.group(1)!); + } + + return null; + } + + String _formatMetricName(String metric) { + switch (metric) { + case 'measurement_consistency': + return 'Angle Consistency'; + case 'length_validation': + return 'Length Validation'; + case 'data_completeness': + return 'Data Completeness'; + default: + return metric.replaceAll('_', ' ').split(' ').map((word) => + word.isEmpty ? '' : word[0].toUpperCase() + word.substring(1)).join(' '); + } + } +} \ No newline at end of file diff --git a/lib/services/survey_quality_service.dart b/lib/services/survey_quality_service.dart new file mode 100644 index 0000000..b8f4c1a --- /dev/null +++ b/lib/services/survey_quality_service.dart @@ -0,0 +1,292 @@ +import 'dart:math'; +import '../models/models.dart'; + +/// Service for scoring survey quality based on various metrics +class SurveyQualityService { + // Scoring weights for different metrics (should sum to 1.0) + static const double _measurementConsistencyWeight = 0.50; + static const double _lengthValidationWeight = 0.35; + static const double _dataCompletenessWeight = 0.15; + + /// Score a single survey section + SurveyQuality scoreSurveySection(Section section) { + final subscores = {}; + final issues = []; + + // Calculate individual metric scores + final measurementScore = _scoreMeasurementConsistency(section, issues); + subscores['measurement_consistency'] = measurementScore; + + final lengthScore = _scoreLengthValidation(section, issues); + subscores['length_validation'] = lengthScore; + + final completenessScore = _scoreDataCompleteness(section, issues); + subscores['data_completeness'] = completenessScore; + + // Calculate weighted total score + final totalScore = (measurementScore * _measurementConsistencyWeight) + + (lengthScore * _lengthValidationWeight) + + (completenessScore * _dataCompletenessWeight); + + // Convert to star rating (1-5 stars) + final stars = _scoreToStars(totalScore); + + return SurveyQuality( + stars: stars, + score: totalScore, + issues: issues, + subscores: subscores, + ); + } + + /// Score measurement consistency (heading and pitch in/out agreement) + double _scoreMeasurementConsistency(Section section, List issues) { + if (section.shots.isEmpty) return 0.0; + + double totalPenalty = 0.0; + int validShots = 0; + double maxHeadingDiff = 0.0; + double maxPitchDiff = 0.0; + + for (int i = 0; i < section.shots.length - 1; i++) { // Exclude EOC shot + final shot = section.shots[i]; + + // Check heading consistency (angles are in degrees * 10) + final headingDiff = ((shot.headingOut - shot.headingIn).abs() / 10.0) % 360; + final normalizedHeadingDiff = headingDiff > 180 ? 360 - headingDiff : headingDiff; + + // Check pitch consistency + final pitchDiff = (shot.pitchOut - shot.pitchIn).abs() / 10.0; + + // Track maximum discrepancies + maxHeadingDiff = max(maxHeadingDiff, normalizedHeadingDiff); + maxPitchDiff = max(maxPitchDiff, pitchDiff); + + if (normalizedHeadingDiff > 20) { + totalPenalty += 30.0; + issues.add(QualityIssue( + category: 'Measurement Consistency', + description: 'Shot ${i + 1}: Large heading difference (${normalizedHeadingDiff.toStringAsFixed(1)}°)', + severity: 'high', + shotIndex: i, + )); + } else if (normalizedHeadingDiff > 10) { + totalPenalty += 15.0; + issues.add(QualityIssue( + category: 'Measurement Consistency', + description: 'Shot ${i + 1}: Moderate heading difference (${normalizedHeadingDiff.toStringAsFixed(1)}°)', + severity: 'medium', + shotIndex: i, + )); + } else if (normalizedHeadingDiff > 5) { + totalPenalty += 5.0; + } + + if (pitchDiff > 20) { + totalPenalty += 20.0; + issues.add(QualityIssue( + category: 'Measurement Consistency', + description: 'Shot ${i + 1}: Large pitch difference (${pitchDiff.toStringAsFixed(1)}°)', + severity: 'high', + shotIndex: i, + )); + } else if (pitchDiff > 10) { + totalPenalty += 10.0; + issues.add(QualityIssue( + category: 'Measurement Consistency', + description: 'Shot ${i + 1}: Moderate pitch difference (${pitchDiff.toStringAsFixed(1)}°)', + severity: 'medium', + shotIndex: i, + )); + } + + validShots++; + } + + if (validShots == 0) return 0.0; + + // Calculate base penalty from individual shots + final averagePenalty = totalPenalty / validShots; + + // Add penalty based on worst single discrepancy + double worstDiscrepancyPenalty = 0.0; + if (maxHeadingDiff > 50) { + worstDiscrepancyPenalty += 40.0; + } else if (maxHeadingDiff > 30) { + worstDiscrepancyPenalty += 25.0; + } else if (maxHeadingDiff > 15) { + worstDiscrepancyPenalty += 10.0; + } + + if (maxPitchDiff > 50) { + worstDiscrepancyPenalty += 30.0; + } else if (maxPitchDiff > 30) { + worstDiscrepancyPenalty += 20.0; + } else if (maxPitchDiff > 15) { + worstDiscrepancyPenalty += 8.0; + } + + return max(0.0, 100.0 - averagePenalty - worstDiscrepancyPenalty); + } + + /// Score length validation (measured vs calculated consistency) + double _scoreLengthValidation(Section section, List issues) { + if (section.shots.isEmpty) return 100.0; + + int totalShots = 0; + double totalLengthPenalty = 0.0; + double maxPercentageDiscrepancy = 0.0; + + for (int i = 0; i < section.shots.length - 1; i++) { // Exclude EOC shot + final shot = section.shots[i]; + totalShots++; + + if (shot.hasProblematicLength()) { + final calculatedLength = shot.getCalculatedLength(); + final percentageDiff = ((shot.length - calculatedLength).abs() / shot.length) * 100.0; + maxPercentageDiscrepancy = max(maxPercentageDiscrepancy, percentageDiff); + + issues.add(QualityIssue( + category: 'Length Validation', + description: 'Shot ${i + 1}: Measured length (${shot.length.toStringAsFixed(2)}m) vs calculated (${calculatedLength.toStringAsFixed(2)}m)', + severity: 'high', + shotIndex: i, + )); + // Heavy penalty for problematic shots + totalLengthPenalty += 50.0; + } else { + // For valid shots, calculate length from inclination and depth change + final depthChange = shot.getDepthChange(); + if (depthChange > 0 && shot.length > 0) { + // Use average inclination from pitchIn and pitchOut (in degrees/10) + final avgPitch = (shot.pitchIn + shot.pitchOut) / 2.0 / 10.0; // Convert to degrees + final radians = avgPitch * pi / 180.0; // Convert to radians + + double calculatedLength; + if (cos(radians).abs() < 0.1) { + // Near vertical (90 degrees), use depth change as length + calculatedLength = depthChange; + } else { + // Calculate length using trigonometry: length = depth_change / sin(inclination) + calculatedLength = depthChange / sin(radians).abs(); + } + + // Compare measured vs calculated length + final lengthDifference = (shot.length - calculatedLength).abs(); + final percentageDifference = (lengthDifference / shot.length) * 100.0; + + // Track maximum discrepancy + maxPercentageDiscrepancy = max(maxPercentageDiscrepancy, percentageDifference); + + if (percentageDifference > 20.0) { + totalLengthPenalty += 25.0; + issues.add(QualityIssue( + category: 'Length Validation', + description: 'Shot ${i + 1}: Large length discrepancy (${percentageDifference.toStringAsFixed(1)}%) - measured: ${shot.length.toStringAsFixed(2)}m, calculated: ${calculatedLength.toStringAsFixed(2)}m', + severity: 'high', + shotIndex: i, + )); + } else if (percentageDifference > 10.0) { + totalLengthPenalty += 15.0; + issues.add(QualityIssue( + category: 'Length Validation', + description: 'Shot ${i + 1}: Moderate length discrepancy (${percentageDifference.toStringAsFixed(1)}%) - measured: ${shot.length.toStringAsFixed(2)}m, calculated: ${calculatedLength.toStringAsFixed(2)}m', + severity: 'medium', + shotIndex: i, + )); + } else if (percentageDifference > 5.0) { + totalLengthPenalty += 5.0; + issues.add(QualityIssue( + category: 'Length Validation', + description: 'Shot ${i + 1}: Minor length discrepancy (${percentageDifference.toStringAsFixed(1)}%)', + severity: 'low', + shotIndex: i, + )); + } + } + } + } + + if (totalShots == 0) return 100.0; + + // Calculate base penalty from individual shots + final averagePenalty = totalLengthPenalty / totalShots; + + // Add penalty based on worst single length discrepancy + double worstLengthPenalty = 0.0; + if (maxPercentageDiscrepancy > 80.0) { + worstLengthPenalty += 50.0; + } else if (maxPercentageDiscrepancy > 50.0) { + worstLengthPenalty += 35.0; + } else if (maxPercentageDiscrepancy > 30.0) { + worstLengthPenalty += 20.0; + } else if (maxPercentageDiscrepancy > 15.0) { + worstLengthPenalty += 10.0; + } + + return max(0.0, 100.0 - averagePenalty - worstLengthPenalty); + } + + /// Score data completeness (missing values, LRUD presence) + double _scoreDataCompleteness(Section section, List issues) { + if (section.shots.isEmpty) return 0.0; + + double score = 100.0; + int validShots = 0; + int shotsWithLRUD = 0; + int shotsWithTemperature = 0; + + for (int i = 0; i < section.shots.length - 1; i++) { // Exclude EOC shot + final shot = section.shots[i]; + validShots++; + + // Check for zero/invalid core measurements + if (shot.length <= 0) { + score -= 20.0; + issues.add(QualityIssue( + category: 'Data Completeness', + description: 'Shot ${i + 1}: Missing or zero length measurement', + severity: 'high', + shotIndex: i, + )); + } + + if (shot.headingIn == 0 && shot.headingOut == 0) { + score -= 15.0; + issues.add(QualityIssue( + category: 'Data Completeness', + description: 'Shot ${i + 1}: Missing heading measurements', + severity: 'medium', + shotIndex: i, + )); + } + + // Check LRUD completeness (bonus scoring) + if (shot.left > 0 || shot.right > 0 || shot.up > 0 || shot.down > 0) { + shotsWithLRUD++; + } + + // Check temperature readings (bonus scoring) + if (shot.temperature != 0) { + shotsWithTemperature++; + } + } + + if (validShots == 0) return 0.0; + + // Bonus points for LRUD and temperature data + final lrudBonus = (shotsWithLRUD / validShots) * 10.0; + final temperatureBonus = (shotsWithTemperature / validShots) * 5.0; + + return max(0.0, min(100.0, score + lrudBonus + temperatureBonus)); + } + + /// Convert numerical score (0-100) to star rating (1-5) + int _scoreToStars(double score) { + if (score >= 90) return 5; + if (score >= 75) return 4; + if (score >= 60) return 3; + if (score >= 40) return 2; + return 1; + } +} \ No newline at end of file diff --git a/lib/widgets/sectioncard.dart b/lib/widgets/sectioncard.dart index 2109235..81d2604 100644 --- a/lib/widgets/sectioncard.dart +++ b/lib/widgets/sectioncard.dart @@ -3,9 +3,10 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../models/models.dart'; +import '../services/survey_quality_service.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:widget_zoom/widget_zoom.dart'; - +import 'star_rating_widget.dart'; import '../mapsurvey.dart'; @@ -22,6 +23,7 @@ class SectionCardState extends State { late SvgPicture? picture; late String rawSvg; late MapSurvey map; + late SurveyQualityService qualityService; static const double displayWidth = 512; static const double displayHeight = 512; static const double margin = 20; @@ -29,6 +31,7 @@ class SectionCardState extends State { @override void initState() { super.initState(); + qualityService = SurveyQualityService(); map = MapSurvey.build(widget.section); rawSvg = buildSVG(map.buildDisplayMap(displayWidth, displayHeight)); picture = SvgPicture.string( @@ -84,7 +87,18 @@ String generateRandomString(int length) { ), ], const SizedBox(width: 6), - Text(DateFormat('yyyy-MM-dd HH:mm').format(widget.section.dateSurvey)), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(DateFormat('yyyy-MM-dd HH:mm').format(widget.section.dateSurvey)), + const SizedBox(height: 2), + StarRatingWidget( + quality: qualityService.scoreSurveySection(widget.section), + size: 14.0, + ), + ], + ), ], ), leading: WidgetZoom( diff --git a/lib/widgets/star_rating_widget.dart b/lib/widgets/star_rating_widget.dart new file mode 100644 index 0000000..25cdc19 --- /dev/null +++ b/lib/widgets/star_rating_widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import '../models/survey_quality.dart'; + +/// Widget that displays a 5-star rating with tooltip +class StarRatingWidget extends StatelessWidget { + final SurveyQuality quality; + final double size; + final Color filledColor; + final Color emptyColor; + + const StarRatingWidget({ + super.key, + required this.quality, + this.size = 16.0, + this.filledColor = Colors.amber, + this.emptyColor = Colors.grey, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: quality.tooltipMessage, + preferBelow: false, + textStyle: const TextStyle( + fontSize: 12, + color: Colors.white, + ), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(4), + ), + waitDuration: const Duration(milliseconds: 500), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (index) { + return Icon( + index < quality.stars ? Icons.star : Icons.star_border, + size: size, + color: index < quality.stars ? filledColor : emptyColor, + ); + }), + ), + ); + } +} \ No newline at end of file