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>
203 lines
6.5 KiB
Go
203 lines
6.5 KiB
Go
package qc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"verofy-backend/models"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type SingleSpanResult struct {
|
|
SegmentID int `json:"segment_id"`
|
|
SegmentName string `json:"segment_name"`
|
|
Type string `json:"type"`
|
|
VertexCount int `json:"vertex_count"`
|
|
IsValid bool `json:"is_valid"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
Geometry map[string]interface{} `json:"geometry,omitempty"`
|
|
}
|
|
|
|
type SingleSpanSummary struct {
|
|
TotalAerialSegments int `json:"total_aerial_segments"`
|
|
ValidSegments int `json:"valid_segments"`
|
|
InvalidSegments int `json:"invalid_segments"`
|
|
PassRate float64 `json:"pass_rate"`
|
|
Results []SingleSpanResult `json:"results"`
|
|
}
|
|
|
|
func SingleSpanRoute(router *gin.Engine, db *gorm.DB, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) {
|
|
// Full single-span summary endpoint
|
|
router.GET("/api/qc/single-span", 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 := CheckSingleSpan(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/single-span/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 := GetInvalidSingleSpanSegments(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),
|
|
})
|
|
})
|
|
}
|
|
|
|
// CheckSingleSpan validates that aerial segments have exactly 2 vertices
|
|
func CheckSingleSpan(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) (*SingleSpanSummary, error) {
|
|
var segments []models.SegmentGeoJSON
|
|
table := fmt.Sprintf("%s.%s", schema, segmentTable)
|
|
|
|
// Query aerial segments using the same pattern as graph_connect.go
|
|
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)
|
|
}
|
|
|
|
summary := &SingleSpanSummary{
|
|
TotalAerialSegments: len(segments),
|
|
Results: make([]SingleSpanResult, 0, len(segments)),
|
|
}
|
|
|
|
for _, segment := range segments {
|
|
result := validateSegmentSpan(segment)
|
|
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
|
|
}
|
|
|
|
// validateSegmentSpan checks if a segment has exactly 2 vertices
|
|
func validateSegmentSpan(segment models.SegmentGeoJSON) SingleSpanResult {
|
|
result := SingleSpanResult{
|
|
SegmentID: int(segment.ID),
|
|
Type: segment.SegmentType,
|
|
IsValid: false,
|
|
}
|
|
|
|
// Parse the geometry to count vertices
|
|
if len(segment.Geometry) > 0 {
|
|
vertexCount, geometry, err := countVerticesFromRawMessage(segment.Geometry)
|
|
if err != nil {
|
|
result.ErrorMessage = fmt.Sprintf("Failed to parse geometry: %v", err)
|
|
return result
|
|
}
|
|
|
|
result.VertexCount = vertexCount
|
|
result.Geometry = geometry
|
|
|
|
// Aerial segments should have exactly 2 vertices (one span)
|
|
if vertexCount == 2 {
|
|
result.IsValid = true
|
|
} else if vertexCount < 2 {
|
|
result.ErrorMessage = fmt.Sprintf("Segment has only %d vertex(es), needs exactly 2 for a valid span", vertexCount)
|
|
} else {
|
|
result.ErrorMessage = fmt.Sprintf("Segment has %d vertices, should be exactly 2 for a single span (pole to pole)", vertexCount)
|
|
}
|
|
} else {
|
|
result.ErrorMessage = "Segment has no geometry data"
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// countVerticesFromRawMessage parses json.RawMessage and counts vertices
|
|
func countVerticesFromRawMessage(geometryRaw json.RawMessage) (int, map[string]interface{}, error) {
|
|
var geometry map[string]interface{}
|
|
|
|
if err := json.Unmarshal(geometryRaw, &geometry); err != nil {
|
|
return 0, nil, fmt.Errorf("invalid GeoJSON: %w", err)
|
|
}
|
|
|
|
geometryType, ok := geometry["type"].(string)
|
|
if !ok {
|
|
return 0, geometry, fmt.Errorf("missing or invalid geometry type")
|
|
}
|
|
|
|
coordinates, ok := geometry["coordinates"]
|
|
if !ok {
|
|
return 0, geometry, fmt.Errorf("missing coordinates")
|
|
}
|
|
|
|
var vertexCount int
|
|
|
|
switch geometryType {
|
|
case "LineString":
|
|
// LineString coordinates are [[x,y], [x,y], ...]
|
|
if coordArray, ok := coordinates.([]interface{}); ok {
|
|
vertexCount = len(coordArray)
|
|
}
|
|
case "MultiLineString":
|
|
// MultiLineString coordinates are [[[x,y], [x,y]], [[x,y], [x,y]]]
|
|
if coordArrays, ok := coordinates.([]interface{}); ok {
|
|
for _, coordArray := range coordArrays {
|
|
if lineCoords, ok := coordArray.([]interface{}); ok {
|
|
vertexCount += len(lineCoords)
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
return 0, geometry, fmt.Errorf("unsupported geometry type: %s", geometryType)
|
|
}
|
|
|
|
return vertexCount, geometry, nil
|
|
}
|
|
|
|
// GetInvalidSingleSpanSegments returns only the segments that failed the check
|
|
func GetInvalidSingleSpanSegments(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) ([]SingleSpanResult, error) {
|
|
summary, err := CheckSingleSpan(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var invalid []SingleSpanResult
|
|
for _, result := range summary.Results {
|
|
if !result.IsValid {
|
|
invalid = append(invalid, result)
|
|
}
|
|
}
|
|
|
|
return invalid, nil
|
|
}
|