Saving current working state before proceeding to Stage 2. Includes: - Backend: Python-based QC validator with shapefile processing - Frontend: Drag-and-drop file upload interface - Sample files for testing - Documentation and revision history 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
247 lines
7.8 KiB
Go
247 lines
7.8 KiB
Go
package qc
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"verofy-backend/models"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AerialEndpointResult struct {
|
|
SegmentID int `json:"segment_id"`
|
|
SegmentName string `json:"segment_name"`
|
|
Type string `json:"type"`
|
|
IsValid bool `json:"is_valid"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
StartPoleCount int `json:"start_pole_count"`
|
|
EndPoleCount int `json:"end_pole_count"`
|
|
StartPoleIDs []int `json:"start_pole_ids,omitempty"`
|
|
EndPoleIDs []int `json:"end_pole_ids,omitempty"`
|
|
Geometry map[string]interface{} `json:"geometry,omitempty"`
|
|
}
|
|
|
|
type AerialEndpointSummary struct {
|
|
TotalAerialSegments int `json:"total_aerial_segments"`
|
|
ValidSegments int `json:"valid_segments"`
|
|
InvalidSegments int `json:"invalid_segments"`
|
|
PassRate float64 `json:"pass_rate"`
|
|
Results []AerialEndpointResult `json:"results"`
|
|
}
|
|
|
|
func AerialEndpointsRoute(router *gin.Engine, db *gorm.DB, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) {
|
|
// Full aerial endpoints summary endpoint
|
|
router.GET("/api/qc/aerial-endpoints", func(c *gin.Context) {
|
|
mapID := c.Query("map_id")
|
|
zone := c.Query("zone")
|
|
if mapID == "" || zone == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "map_id and zone are required"})
|
|
return
|
|
}
|
|
|
|
summary, err := CheckAerialEndpoints(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, summary)
|
|
})
|
|
|
|
// Invalid segments only endpoint
|
|
router.GET("/api/qc/aerial-endpoints/invalid", func(c *gin.Context) {
|
|
mapID := c.Query("map_id")
|
|
zone := c.Query("zone")
|
|
if mapID == "" || zone == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "map_id and zone are required"})
|
|
return
|
|
}
|
|
|
|
invalid, err := GetInvalidAerialEndpoints(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"invalid_segments": invalid,
|
|
"count": len(invalid),
|
|
})
|
|
})
|
|
|
|
// Update QC flags endpoint
|
|
router.POST("/api/qc/aerial-endpoints/update-flags", func(c *gin.Context) {
|
|
var request struct {
|
|
SegmentIDs []int `json:"segment_ids"`
|
|
MapID string `json:"map_id"`
|
|
Zone string `json:"zone"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := UpdateAerialEndpointFlags(db, request.SegmentIDs, schema, segmentTable, idCol, qcFlagCol)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Updated QC flags for %d segments", len(request.SegmentIDs))})
|
|
})
|
|
}
|
|
|
|
// CheckAerialEndpoints validates that aerial segments have exactly one pole at each endpoint
|
|
func CheckAerialEndpoints(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) (*AerialEndpointSummary, error) {
|
|
var segments []models.SegmentGeoJSON
|
|
table := fmt.Sprintf("%s.%s", schema, segmentTable)
|
|
|
|
// Query aerial segments
|
|
err := db.Table(table).
|
|
Select(fmt.Sprintf("id_0, %s, segment_type, segment_status, %s, protection_status, %s, ST_AsGeoJSON(ST_Transform(geom, 4326))::json AS geometry", mapIDCol, idCol, qcFlagCol)).
|
|
Where(fmt.Sprintf("%s = ? AND %s = ? AND LOWER(segment_type) = ?", mapIDCol, zoneCol), mapID, zone, "aerial").
|
|
Find(&segments).Error
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch aerial segments: %w", err)
|
|
}
|
|
|
|
// Get poles for the same map
|
|
poles, err := getPoles(db, mapID, schema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch poles: %w", err)
|
|
}
|
|
|
|
summary := &AerialEndpointSummary{
|
|
TotalAerialSegments: len(segments),
|
|
Results: make([]AerialEndpointResult, 0, len(segments)),
|
|
}
|
|
|
|
for _, segment := range segments {
|
|
result := validateAerialEndpoints(segment, poles)
|
|
summary.Results = append(summary.Results, result)
|
|
|
|
if result.IsValid {
|
|
summary.ValidSegments++
|
|
} else {
|
|
summary.InvalidSegments++
|
|
}
|
|
}
|
|
|
|
// Calculate pass rate
|
|
if summary.TotalAerialSegments > 0 {
|
|
summary.PassRate = float64(summary.ValidSegments) / float64(summary.TotalAerialSegments) * 100
|
|
}
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// validateAerialEndpoints checks if aerial segment has exactly one pole at each endpoint
|
|
func validateAerialEndpoints(segment models.SegmentGeoJSON, poles []models.PolesGeoJSON) AerialEndpointResult {
|
|
result := AerialEndpointResult{
|
|
SegmentID: int(segment.ID),
|
|
Type: segment.SegmentType,
|
|
IsValid: false,
|
|
}
|
|
|
|
// Parse the geometry to get start and end coordinates
|
|
if len(segment.Geometry) == 0 {
|
|
result.ErrorMessage = "Segment has no geometry data"
|
|
return result
|
|
}
|
|
|
|
startCoord, endCoord, geometry, err := getSegmentEndpoints(segment.Geometry)
|
|
if err != nil {
|
|
result.ErrorMessage = fmt.Sprintf("Failed to parse geometry: %v", err)
|
|
return result
|
|
}
|
|
|
|
result.Geometry = geometry
|
|
|
|
// Check for poles near start and end coordinates
|
|
// Using a buffer distance of ~10 meters (0.0001 degrees approximately)
|
|
bufferDistance := 0.0001
|
|
|
|
startPoles, startPoleIDs := getPolesNearCoordinate(startCoord, poles, bufferDistance)
|
|
endPoles, endPoleIDs := getPolesNearCoordinate(endCoord, poles, bufferDistance)
|
|
|
|
result.StartPoleCount = startPoles
|
|
result.EndPoleCount = endPoles
|
|
result.StartPoleIDs = startPoleIDs
|
|
result.EndPoleIDs = endPoleIDs
|
|
|
|
// Valid if exactly ONE pole at each endpoint
|
|
if startPoles == 1 && endPoles == 1 {
|
|
result.IsValid = true
|
|
} else {
|
|
errorParts := []string{}
|
|
if startPoles == 0 {
|
|
errorParts = append(errorParts, "no pole at start")
|
|
} else if startPoles > 1 {
|
|
errorParts = append(errorParts, fmt.Sprintf("%d poles at start (should be 1)", startPoles))
|
|
}
|
|
if endPoles == 0 {
|
|
errorParts = append(errorParts, "no pole at end")
|
|
} else if endPoles > 1 {
|
|
errorParts = append(errorParts, fmt.Sprintf("%d poles at end (should be 1)", endPoles))
|
|
}
|
|
|
|
if len(errorParts) > 0 {
|
|
result.ErrorMessage = fmt.Sprintf("Aerial segment pole issues: %v", errorParts)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// getPolesNearCoordinate counts poles within buffer distance of coordinate and returns their IDs
|
|
func getPolesNearCoordinate(coord [2]float64, poles []models.PolesGeoJSON, buffer float64) (int, []int) {
|
|
count := 0
|
|
poleIDs := []int{}
|
|
|
|
for _, pole := range poles {
|
|
if poleCoord, err := getPointCoordinates(pole.Geometry); err == nil {
|
|
if distance(coord, poleCoord) <= buffer {
|
|
count++
|
|
if pole.ID != nil {
|
|
poleIDs = append(poleIDs, *pole.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return count, poleIDs
|
|
}
|
|
|
|
// GetInvalidAerialEndpoints returns only the segments that failed the check
|
|
func GetInvalidAerialEndpoints(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) ([]AerialEndpointResult, error) {
|
|
summary, err := CheckAerialEndpoints(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var invalid []AerialEndpointResult
|
|
for _, result := range summary.Results {
|
|
if !result.IsValid {
|
|
invalid = append(invalid, result)
|
|
}
|
|
}
|
|
|
|
return invalid, nil
|
|
}
|
|
|
|
// UpdateAerialEndpointFlags updates QC flags for invalid segments
|
|
func UpdateAerialEndpointFlags(db *gorm.DB, segmentIDs []int, schema, segmentTable, idCol, qcFlagCol string) error {
|
|
if len(segmentIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
table := fmt.Sprintf("%s.%s", schema, segmentTable)
|
|
|
|
return db.Table(table).
|
|
Where(fmt.Sprintf("%s IN ?", idCol), segmentIDs).
|
|
Update(qcFlagCol, "aerial_endpoint_issue").Error
|
|
}
|