Initial commit - Stage 1 working version
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>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=C:\Users\AlexanderHall\Downloads\Auto_LLD-QC-main.zip
|
||||
@@ -0,0 +1,53 @@
|
||||
package qc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"verofy-backend/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func GraphConnectivityRoute(router *gin.Engine, db *gorm.DB, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) {
|
||||
router.GET("/api/qc/connectivity", 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
|
||||
}
|
||||
|
||||
var segments []models.SegmentGeoJSON
|
||||
table := fmt.Sprintf("%s.%s", schema, segmentTable)
|
||||
|
||||
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 = ?", mapIDCol, zoneCol), mapID, zone).
|
||||
Find(&segments)
|
||||
|
||||
features := []map[string]interface{}{}
|
||||
for _, s := range segments {
|
||||
var geometry interface{}
|
||||
if err := json.Unmarshal(s.Geometry, &geometry); err == nil {
|
||||
features = append(features, map[string]interface{}{
|
||||
"type": "Feature",
|
||||
"geometry": geometry,
|
||||
"properties": map[string]interface{}{
|
||||
"id_0": s.ID0,
|
||||
"mapid": s.MapID,
|
||||
"segment_type": s.SegmentType,
|
||||
"segment_status": s.SegmentStatus,
|
||||
"id": s.ID,
|
||||
"protection_status": s.ProtectionStatus,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=C:\Users\AlexanderHall\Downloads\Auto_LLD-QC-main.zip
|
||||
@@ -0,0 +1 @@
|
||||
package qc
|
||||
@@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=C:\Users\AlexanderHall\Downloads\Auto_LLD-QC-main.zip
|
||||
@@ -0,0 +1,202 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=C:\Users\AlexanderHall\Downloads\Auto_LLD-QC-main.zip
|
||||
@@ -0,0 +1,125 @@
|
||||
package qc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"verofy-backend/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SiteConnectivityRoute(router *gin.Engine, db *gorm.DB, schema string) {
|
||||
router.GET("/api/qc/site-connectivity", func(c *gin.Context) {
|
||||
mapID := c.Query("map_id")
|
||||
zone := c.Query("zone")
|
||||
maxDistanceStr := c.Query("max_distance")
|
||||
|
||||
if mapID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "map_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default max distance is 50 meters
|
||||
maxDistance := 50.0
|
||||
if maxDistanceStr != "" {
|
||||
if dist, err := strconv.ParseFloat(maxDistanceStr, 64); err == nil {
|
||||
maxDistance = dist
|
||||
}
|
||||
}
|
||||
|
||||
// Get all sites for the market (and zone if specified)
|
||||
var sites []models.SitesGeoJSON
|
||||
siteQuery := db.Table(fmt.Sprintf("%s.sites", schema)).
|
||||
Select("gid, id, mapprojectid, name, address1, city, state, zip, ST_AsGeoJSON(geom)::json AS geometry")
|
||||
|
||||
if mapID != "" {
|
||||
siteQuery = siteQuery.Where("mapprojectid = ?", mapID)
|
||||
}
|
||||
|
||||
siteQuery.Find(&sites)
|
||||
|
||||
// Get all segments for connectivity analysis
|
||||
var segments []models.SegmentGeoJSON
|
||||
segmentQuery := db.Table(fmt.Sprintf("%s.segment2", schema)).
|
||||
Select("id_0, mapid, segment_type, segment_status, id, protection_status, qc_flag, ST_AsGeoJSON(geom)::json AS geometry")
|
||||
|
||||
if mapID != "" {
|
||||
segmentQuery = segmentQuery.Where("mapid = ?", mapID)
|
||||
}
|
||||
if zone != "" {
|
||||
segmentQuery = segmentQuery.Where("group_1 = ?", zone)
|
||||
}
|
||||
|
||||
segmentQuery.Find(&segments)
|
||||
|
||||
// Analyze connectivity for each site
|
||||
results := []map[string]interface{}{}
|
||||
connectedCount := 0
|
||||
disconnectedCount := 0
|
||||
|
||||
for _, site := range sites {
|
||||
if len(site.Geometry) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use PostGIS to find the nearest segment within max distance
|
||||
var nearestDistance float64
|
||||
nearestQuery := fmt.Sprintf(`
|
||||
SELECT ST_Distance(
|
||||
ST_Transform(sites.geom, 3857),
|
||||
ST_Transform(segments.geom, 3857)
|
||||
) as distance
|
||||
FROM %s.sites sites, %s.segment2 segments
|
||||
WHERE sites.gid = ? AND segments.mapid = ?
|
||||
ORDER BY ST_Distance(
|
||||
ST_Transform(sites.geom, 3857),
|
||||
ST_Transform(segments.geom, 3857)
|
||||
)
|
||||
LIMIT 1
|
||||
`, schema, schema)
|
||||
|
||||
db.Raw(nearestQuery, site.GID, mapID).Scan(&nearestDistance)
|
||||
|
||||
isConnected := nearestDistance <= maxDistance
|
||||
status := "connected"
|
||||
if !isConnected {
|
||||
status = "disconnected"
|
||||
disconnectedCount++
|
||||
} else {
|
||||
connectedCount++
|
||||
}
|
||||
|
||||
// Update the site's connectivity status in the database
|
||||
updateQuery := fmt.Sprintf("UPDATE %s.sites SET connectivity_status = ?, connectivity_distance = ? WHERE gid = ?", schema)
|
||||
db.Exec(updateQuery, status, nearestDistance, site.GID)
|
||||
|
||||
siteResult := map[string]interface{}{
|
||||
"site_id": site.GID,
|
||||
"site_name": site.Name,
|
||||
"mapprojectid": site.MapProjectID,
|
||||
"is_connected": isConnected,
|
||||
"nearest_distance": nearestDistance,
|
||||
"connectivity_status": status,
|
||||
"geometry": site.Geometry,
|
||||
"address": site.Address1,
|
||||
"city": site.City,
|
||||
"state": site.State,
|
||||
}
|
||||
|
||||
results = append(results, siteResult)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"total_sites": len(sites),
|
||||
"connected_sites": connectedCount,
|
||||
"disconnected_sites": disconnectedCount,
|
||||
"connectivity_rate": float64(connectedCount) / float64(len(sites)) * 100,
|
||||
"max_distance_meters": maxDistance,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=C:\Users\AlexanderHall\Downloads\Auto_LLD-QC-main.zip
|
||||
@@ -0,0 +1,412 @@
|
||||
package qc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"verofy-backend/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UndergroundEndpointResult 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"`
|
||||
StartEndpoint string `json:"start_endpoint,omitempty"`
|
||||
EndEndpoint string `json:"end_endpoint,omitempty"`
|
||||
Geometry map[string]interface{} `json:"geometry,omitempty"`
|
||||
}
|
||||
|
||||
type UndergroundEndpointSummary struct {
|
||||
TotalUndergroundSegments int `json:"total_underground_segments"`
|
||||
ValidSegments int `json:"valid_segments"`
|
||||
InvalidSegments int `json:"invalid_segments"`
|
||||
PassRate float64 `json:"pass_rate"`
|
||||
Results []UndergroundEndpointResult `json:"results"`
|
||||
}
|
||||
|
||||
func UndergroundEndpointsRoute(router *gin.Engine, db *gorm.DB, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) {
|
||||
// Full underground endpoints summary endpoint
|
||||
router.GET("/api/qc/underground-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 := CheckUndergroundEndpoints(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/underground-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 := GetInvalidUndergroundEndpoints(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/underground-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 := UpdateUndergroundEndpointFlags(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))})
|
||||
})
|
||||
}
|
||||
|
||||
// CheckUndergroundEndpoints validates that underground segments have poles or access points at both endpoints
|
||||
func CheckUndergroundEndpoints(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) (*UndergroundEndpointSummary, error) {
|
||||
var segments []models.SegmentGeoJSON
|
||||
table := fmt.Sprintf("%s.%s", schema, segmentTable)
|
||||
|
||||
// Query underground segments
|
||||
err := db.Table(table).
|
||||
Select(fmt.Sprintf("id_0, %s, segment_type, segment_status, %s, protection_status, %s, ST_AsGeoJSON(geom)::json AS geometry", mapIDCol, idCol, qcFlagCol)).
|
||||
Where(fmt.Sprintf("%s = ? AND %s = ? AND LOWER(segment_type) = ?", mapIDCol, zoneCol), mapID, zone, "underground").
|
||||
Find(&segments).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch underground segments: %w", err)
|
||||
}
|
||||
|
||||
// Get poles and access points for the same map/zone
|
||||
poles, err := getPoles(db, mapID, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch poles: %w", err)
|
||||
}
|
||||
|
||||
accessPoints, err := getAccessPoints(db, mapID, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch access points: %w", err)
|
||||
}
|
||||
|
||||
summary := &UndergroundEndpointSummary{
|
||||
TotalUndergroundSegments: len(segments),
|
||||
Results: make([]UndergroundEndpointResult, 0, len(segments)),
|
||||
}
|
||||
|
||||
for _, segment := range segments {
|
||||
result := validateUndergroundEndpoints(segment, poles, accessPoints)
|
||||
summary.Results = append(summary.Results, result)
|
||||
|
||||
if result.IsValid {
|
||||
summary.ValidSegments++
|
||||
} else {
|
||||
summary.InvalidSegments++
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate pass rate
|
||||
if summary.TotalUndergroundSegments > 0 {
|
||||
summary.PassRate = float64(summary.ValidSegments) / float64(summary.TotalUndergroundSegments) * 100
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// validateUndergroundEndpoints checks if underground segment has poles/access points at both ends
|
||||
func validateUndergroundEndpoints(segment models.SegmentGeoJSON, poles []models.PolesGeoJSON, accessPoints []models.AccessPointGeoJSON) UndergroundEndpointResult {
|
||||
result := UndergroundEndpointResult{
|
||||
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/access points near start and end coordinates
|
||||
// Using a buffer distance of ~10 meters (0.0001 degrees approximately)
|
||||
bufferDistance := 0.0001
|
||||
|
||||
startHasEndpoint := hasEndpointNearCoordinate(startCoord, poles, accessPoints, bufferDistance)
|
||||
endHasEndpoint := hasEndpointNearCoordinate(endCoord, poles, accessPoints, bufferDistance)
|
||||
|
||||
startEndpointType := getEndpointTypeNearCoordinate(startCoord, poles, accessPoints, bufferDistance)
|
||||
endEndpointType := getEndpointTypeNearCoordinate(endCoord, poles, accessPoints, bufferDistance)
|
||||
|
||||
result.StartEndpoint = startEndpointType
|
||||
result.EndEndpoint = endEndpointType
|
||||
|
||||
if startHasEndpoint && endHasEndpoint {
|
||||
result.IsValid = true
|
||||
} else {
|
||||
errorParts := []string{}
|
||||
if !startHasEndpoint {
|
||||
errorParts = append(errorParts, "no pole/access point at start")
|
||||
}
|
||||
if !endHasEndpoint {
|
||||
errorParts = append(errorParts, "no pole/access point at end")
|
||||
}
|
||||
result.ErrorMessage = fmt.Sprintf("Underground segment missing endpoints: %s", fmt.Sprintf("%s", errorParts))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getSegmentEndpoints extracts start and end coordinates from segment geometry
|
||||
func getSegmentEndpoints(geometryRaw json.RawMessage) ([2]float64, [2]float64, map[string]interface{}, error) {
|
||||
var geometry map[string]interface{}
|
||||
|
||||
if err := json.Unmarshal(geometryRaw, &geometry); err != nil {
|
||||
return [2]float64{}, [2]float64{}, nil, fmt.Errorf("invalid GeoJSON: %w", err)
|
||||
}
|
||||
|
||||
geometryType, ok := geometry["type"].(string)
|
||||
if !ok {
|
||||
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("missing or invalid geometry type")
|
||||
}
|
||||
|
||||
coordinates, ok := geometry["coordinates"]
|
||||
if !ok {
|
||||
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("missing coordinates")
|
||||
}
|
||||
|
||||
var startCoord, endCoord [2]float64
|
||||
|
||||
switch geometryType {
|
||||
case "LineString":
|
||||
// LineString coordinates are [[x,y], [x,y], ...]
|
||||
if coordArray, ok := coordinates.([]interface{}); ok && len(coordArray) >= 2 {
|
||||
if startPoint, ok := coordArray[0].([]interface{}); ok && len(startPoint) >= 2 {
|
||||
if x, ok := startPoint[0].(float64); ok {
|
||||
startCoord[0] = x
|
||||
}
|
||||
if y, ok := startPoint[1].(float64); ok {
|
||||
startCoord[1] = y
|
||||
}
|
||||
}
|
||||
if endPoint, ok := coordArray[len(coordArray)-1].([]interface{}); ok && len(endPoint) >= 2 {
|
||||
if x, ok := endPoint[0].(float64); ok {
|
||||
endCoord[0] = x
|
||||
}
|
||||
if y, ok := endPoint[1].(float64); ok {
|
||||
endCoord[1] = y
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("invalid LineString coordinates")
|
||||
}
|
||||
case "MultiLineString":
|
||||
// For MultiLineString, use first and last coordinates of the entire geometry
|
||||
if coordArrays, ok := coordinates.([]interface{}); ok && len(coordArrays) > 0 {
|
||||
// Get start from first LineString
|
||||
if firstLine, ok := coordArrays[0].([]interface{}); ok && len(firstLine) >= 2 {
|
||||
if startPoint, ok := firstLine[0].([]interface{}); ok && len(startPoint) >= 2 {
|
||||
if x, ok := startPoint[0].(float64); ok {
|
||||
startCoord[0] = x
|
||||
}
|
||||
if y, ok := startPoint[1].(float64); ok {
|
||||
startCoord[1] = y
|
||||
}
|
||||
}
|
||||
}
|
||||
// Get end from last LineString
|
||||
if lastLine, ok := coordArrays[len(coordArrays)-1].([]interface{}); ok && len(lastLine) >= 2 {
|
||||
if endPoint, ok := lastLine[len(lastLine)-1].([]interface{}); ok && len(endPoint) >= 2 {
|
||||
if x, ok := endPoint[0].(float64); ok {
|
||||
endCoord[0] = x
|
||||
}
|
||||
if y, ok := endPoint[1].(float64); ok {
|
||||
endCoord[1] = y
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("invalid MultiLineString coordinates")
|
||||
}
|
||||
default:
|
||||
return [2]float64{}, [2]float64{}, geometry, fmt.Errorf("unsupported geometry type: %s", geometryType)
|
||||
}
|
||||
|
||||
return startCoord, endCoord, geometry, nil
|
||||
}
|
||||
|
||||
// hasEndpointNearCoordinate checks if there's a pole or access point within buffer distance of coordinate
|
||||
func hasEndpointNearCoordinate(coord [2]float64, poles []models.PolesGeoJSON, accessPoints []models.AccessPointGeoJSON, buffer float64) bool {
|
||||
// Check poles
|
||||
for _, pole := range poles {
|
||||
if poleCoord, err := getPointCoordinates(pole.Geometry); err == nil {
|
||||
if distance(coord, poleCoord) <= buffer {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check access points
|
||||
for _, ap := range accessPoints {
|
||||
if apCoord, err := getPointCoordinates(ap.Geometry); err == nil {
|
||||
if distance(coord, apCoord) <= buffer {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getEndpointTypeNearCoordinate returns the type of endpoint near the coordinate
|
||||
func getEndpointTypeNearCoordinate(coord [2]float64, poles []models.PolesGeoJSON, accessPoints []models.AccessPointGeoJSON, buffer float64) string {
|
||||
// Check poles first
|
||||
for _, pole := range poles {
|
||||
if poleCoord, err := getPointCoordinates(pole.Geometry); err == nil {
|
||||
if distance(coord, poleCoord) <= buffer {
|
||||
return fmt.Sprintf("Pole (ID: %d)", *pole.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check access points
|
||||
for _, ap := range accessPoints {
|
||||
if apCoord, err := getPointCoordinates(ap.Geometry); err == nil {
|
||||
if distance(coord, apCoord) <= buffer {
|
||||
return fmt.Sprintf("Access Point (ID: %d)", *ap.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "None"
|
||||
}
|
||||
|
||||
// getPointCoordinates extracts coordinates from point geometry
|
||||
func getPointCoordinates(geometryRaw json.RawMessage) ([2]float64, error) {
|
||||
var geometry map[string]interface{}
|
||||
|
||||
if err := json.Unmarshal(geometryRaw, &geometry); err != nil {
|
||||
return [2]float64{}, fmt.Errorf("invalid GeoJSON: %w", err)
|
||||
}
|
||||
|
||||
geometryType, ok := geometry["type"].(string)
|
||||
if !ok || geometryType != "Point" {
|
||||
return [2]float64{}, fmt.Errorf("not a Point geometry")
|
||||
}
|
||||
|
||||
coordinates, ok := geometry["coordinates"].([]interface{})
|
||||
if !ok || len(coordinates) < 2 {
|
||||
return [2]float64{}, fmt.Errorf("invalid Point coordinates")
|
||||
}
|
||||
|
||||
var coord [2]float64
|
||||
if x, ok := coordinates[0].(float64); ok {
|
||||
coord[0] = x
|
||||
}
|
||||
if y, ok := coordinates[1].(float64); ok {
|
||||
coord[1] = y
|
||||
}
|
||||
|
||||
return coord, nil
|
||||
}
|
||||
|
||||
// distance calculates simple Euclidean distance between two coordinates
|
||||
func distance(a, b [2]float64) float64 {
|
||||
dx := a[0] - b[0]
|
||||
dy := a[1] - b[1]
|
||||
return dx*dx + dy*dy // Using squared distance for efficiency
|
||||
}
|
||||
|
||||
// getPoles fetches poles for the given map ID
|
||||
func getPoles(db *gorm.DB, mapID, schema string) ([]models.PolesGeoJSON, error) {
|
||||
var poles []models.PolesGeoJSON
|
||||
table := fmt.Sprintf("%s.poles", schema)
|
||||
|
||||
err := db.Table(table).
|
||||
Select("gid, id, mapprojectid, name, tags, group1, group2, owner, poleheight, attachmentheight, ST_AsGeoJSON(geom)::json AS geometry").
|
||||
Where("mapprojectid = ?", mapID).
|
||||
Find(&poles).Error
|
||||
|
||||
return poles, err
|
||||
}
|
||||
|
||||
// getAccessPoints fetches access points for the given map ID
|
||||
func getAccessPoints(db *gorm.DB, mapID, schema string) ([]models.AccessPointGeoJSON, error) {
|
||||
var accessPoints []models.AccessPointGeoJSON
|
||||
table := fmt.Sprintf("%s.access_points", schema)
|
||||
|
||||
err := db.Table(table).
|
||||
Select(`gid, id, name, mapprojectid, latitude, longitude, manufacturer, size, locked, description, aka,
|
||||
createdby, createddate, modifiedby, modifieddate, historyid, group1, group2, typeid, statusid,
|
||||
crmvendorid, billdate, ST_AsGeoJSON(geom)::json AS geometry`).
|
||||
Where("mapprojectid = ?", mapID).
|
||||
Find(&accessPoints).Error
|
||||
|
||||
return accessPoints, err
|
||||
}
|
||||
|
||||
// GetInvalidUndergroundEndpoints returns only the segments that failed the check
|
||||
func GetInvalidUndergroundEndpoints(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) ([]UndergroundEndpointResult, error) {
|
||||
summary, err := CheckUndergroundEndpoints(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var invalid []UndergroundEndpointResult
|
||||
for _, result := range summary.Results {
|
||||
if !result.IsValid {
|
||||
invalid = append(invalid, result)
|
||||
}
|
||||
}
|
||||
|
||||
return invalid, nil
|
||||
}
|
||||
|
||||
// UpdateUndergroundEndpointFlags updates QC flags for invalid segments
|
||||
func UpdateUndergroundEndpointFlags(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, "underground_endpoint_issue").Error
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=C:\Users\AlexanderHall\Downloads\Auto_LLD-QC-main.zip
|
||||
@@ -0,0 +1,743 @@
|
||||
package qc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"verofy-backend/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ZoneContainmentResult struct {
|
||||
ElementID int `json:"element_id"`
|
||||
ElementType string `json:"element_type"` // "segment", "site", "pole", "access_point"
|
||||
ElementName string `json:"element_name,omitempty"`
|
||||
AssignedZone *string `json:"assigned_zone"`
|
||||
ActualZones []string `json:"actual_zones,omitempty"`
|
||||
IsValid bool `json:"is_valid"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Geometry map[string]interface{} `json:"geometry,omitempty"`
|
||||
}
|
||||
|
||||
type ZoneContainmentSummary struct {
|
||||
TotalElements int `json:"total_elements"`
|
||||
ValidElements int `json:"valid_elements"`
|
||||
InvalidElements int `json:"invalid_elements"`
|
||||
PassRate float64 `json:"pass_rate"`
|
||||
ByType map[string]TypeSummary `json:"by_type"`
|
||||
Results []ZoneContainmentResult `json:"results"`
|
||||
}
|
||||
|
||||
type TypeSummary struct {
|
||||
Total int `json:"total"`
|
||||
Valid int `json:"valid"`
|
||||
Invalid int `json:"invalid"`
|
||||
}
|
||||
|
||||
func ZoneContainmentRoute(router *gin.Engine, db *gorm.DB, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) {
|
||||
// Full zone containment summary endpoint
|
||||
router.GET("/api/qc/zone-containment", 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 := CheckZoneContainment(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 elements only endpoint
|
||||
router.GET("/api/qc/zone-containment/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 := GetInvalidZoneContainment(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_elements": invalid,
|
||||
"count": len(invalid),
|
||||
})
|
||||
})
|
||||
|
||||
// Update QC flags endpoint (for segments)
|
||||
router.POST("/api/qc/zone-containment/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 := UpdateZoneContainmentFlags(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))})
|
||||
})
|
||||
}
|
||||
|
||||
// CheckZoneContainment validates that network elements are within their assigned zones
|
||||
func CheckZoneContainment(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) (*ZoneContainmentSummary, error) {
|
||||
summary := &ZoneContainmentSummary{
|
||||
Results: make([]ZoneContainmentResult, 0),
|
||||
ByType: map[string]TypeSummary{
|
||||
"segment": {Total: 0, Valid: 0, Invalid: 0},
|
||||
"site": {Total: 0, Valid: 0, Invalid: 0},
|
||||
"pole": {Total: 0, Valid: 0, Invalid: 0},
|
||||
"access_point": {Total: 0, Valid: 0, Invalid: 0},
|
||||
},
|
||||
}
|
||||
|
||||
// Get all zone polygons
|
||||
zones, err := getZonePolygons(db, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch zone polygons: %w", err)
|
||||
}
|
||||
|
||||
// Check segments
|
||||
segmentResults, err := checkSegmentZones(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, zones)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check segments: %w", err)
|
||||
}
|
||||
summary.Results = append(summary.Results, segmentResults...)
|
||||
|
||||
// Check sites
|
||||
siteResults, err := checkSiteZones(db, mapID, schema, zones)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check sites: %w", err)
|
||||
}
|
||||
summary.Results = append(summary.Results, siteResults...)
|
||||
|
||||
// Check poles
|
||||
poleResults, err := checkPoleZones(db, mapID, schema, zones)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check poles: %w", err)
|
||||
}
|
||||
summary.Results = append(summary.Results, poleResults...)
|
||||
|
||||
// Check access points
|
||||
accessPointResults, err := checkAccessPointZones(db, mapID, schema, zones)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check access points: %w", err)
|
||||
}
|
||||
summary.Results = append(summary.Results, accessPointResults...)
|
||||
|
||||
// Calculate summary statistics
|
||||
for _, result := range summary.Results {
|
||||
typeSummary := summary.ByType[result.ElementType]
|
||||
typeSummary.Total++
|
||||
if result.IsValid {
|
||||
typeSummary.Valid++
|
||||
summary.ValidElements++
|
||||
} else {
|
||||
typeSummary.Invalid++
|
||||
summary.InvalidElements++
|
||||
}
|
||||
summary.ByType[result.ElementType] = typeSummary
|
||||
summary.TotalElements++
|
||||
}
|
||||
|
||||
// Calculate pass rate
|
||||
if summary.TotalElements > 0 {
|
||||
summary.PassRate = float64(summary.ValidElements) / float64(summary.TotalElements) * 100
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// getZonePolygons fetches all zone polygons from the info table
|
||||
func getZonePolygons(db *gorm.DB, schema string) ([]models.InfoGeoJSON, error) {
|
||||
var zones []models.InfoGeoJSON
|
||||
table := fmt.Sprintf("%s.info", schema)
|
||||
|
||||
err := db.Table(table).
|
||||
Select("id, name, group_1, ST_AsGeoJSON(geom)::json AS geometry").
|
||||
Find(&zones).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return zones, nil
|
||||
}
|
||||
|
||||
// checkSegmentZones validates segments against their assigned zones using PostGIS
|
||||
func checkSegmentZones(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol string, zones []models.InfoGeoJSON) ([]ZoneContainmentResult, error) {
|
||||
// Use PostGIS to check intersection directly in the database
|
||||
type SegmentZoneCheck struct {
|
||||
ID int `gorm:"column:id"`
|
||||
SegmentType string `gorm:"column:segment_type"`
|
||||
AssignedZone *string `gorm:"column:assigned_zone"`
|
||||
ActualZones string `gorm:"column:actual_zones"`
|
||||
Geometry json.RawMessage `gorm:"column:geometry"`
|
||||
}
|
||||
|
||||
var results []SegmentZoneCheck
|
||||
table := fmt.Sprintf("%s.%s", schema, segmentTable)
|
||||
infoTable := fmt.Sprintf("%s.info", schema)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
s.%s as id,
|
||||
s.segment_type,
|
||||
s."%s" as assigned_zone,
|
||||
STRING_AGG(i.group_1, ',') as actual_zones,
|
||||
ST_AsGeoJSON(ST_Transform(s.geom, 4326))::json AS geometry
|
||||
FROM %s s
|
||||
LEFT JOIN %s i ON ST_Intersects(ST_Transform(s.geom, 4326), i.geom)
|
||||
WHERE s.%s = ? AND s."%s" = ?
|
||||
GROUP BY s.%s, s.segment_type, s."%s", s.geom
|
||||
`, idCol, zoneCol, table, infoTable, mapIDCol, zoneCol, idCol, zoneCol)
|
||||
|
||||
err := db.Raw(query, mapID, zone).Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
qcResults := make([]ZoneContainmentResult, 0, len(results))
|
||||
|
||||
for _, seg := range results {
|
||||
result := ZoneContainmentResult{
|
||||
ElementID: seg.ID,
|
||||
ElementType: "segment",
|
||||
ElementName: seg.SegmentType,
|
||||
AssignedZone: seg.AssignedZone,
|
||||
Geometry: parseGeometryToMap(seg.Geometry),
|
||||
}
|
||||
|
||||
// Parse actual zones
|
||||
if seg.ActualZones != "" {
|
||||
result.ActualZones = splitZones(seg.ActualZones)
|
||||
} else {
|
||||
result.ActualZones = []string{}
|
||||
}
|
||||
|
||||
// Check validity
|
||||
if seg.AssignedZone == nil || *seg.AssignedZone == "" {
|
||||
result.IsValid = false
|
||||
result.ErrorMessage = "Element has no assigned zone (NULL or blank)"
|
||||
} else if len(result.ActualZones) == 0 {
|
||||
result.IsValid = false
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but not found in any zone", *seg.AssignedZone)
|
||||
} else {
|
||||
// Check if assigned zone is in actual zones
|
||||
result.IsValid = false
|
||||
for _, actualZone := range result.ActualZones {
|
||||
if actualZone == *seg.AssignedZone {
|
||||
result.IsValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !result.IsValid {
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but found in: %v", *seg.AssignedZone, result.ActualZones)
|
||||
}
|
||||
}
|
||||
|
||||
qcResults = append(qcResults, result)
|
||||
}
|
||||
|
||||
return qcResults, nil
|
||||
}
|
||||
|
||||
// Helper function to split comma-separated zones
|
||||
func splitZones(zones string) []string {
|
||||
if zones == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := []string{}
|
||||
for _, z := range strings.Split(zones, ",") {
|
||||
z = strings.TrimSpace(z)
|
||||
if z != "" {
|
||||
parts = append(parts, z)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// Helper to parse geometry JSON to map
|
||||
func parseGeometryToMap(geomJSON json.RawMessage) map[string]interface{} {
|
||||
var geomMap map[string]interface{}
|
||||
if err := json.Unmarshal(geomJSON, &geomMap); err != nil {
|
||||
return nil
|
||||
}
|
||||
return geomMap
|
||||
}
|
||||
|
||||
// checkSiteZones validates sites against their assigned zones using PostGIS
|
||||
func checkSiteZones(db *gorm.DB, mapID, schema string, zones []models.InfoGeoJSON) ([]ZoneContainmentResult, error) {
|
||||
type SiteZoneCheck struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Name *string `gorm:"column:name"`
|
||||
AssignedZone *string `gorm:"column:assigned_zone"`
|
||||
ActualZones string `gorm:"column:actual_zones"`
|
||||
Geometry json.RawMessage `gorm:"column:geometry"`
|
||||
}
|
||||
|
||||
var results []SiteZoneCheck
|
||||
table := fmt.Sprintf("%s.sites", schema)
|
||||
infoTable := fmt.Sprintf("%s.info", schema)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(s.id, s.gid) as id,
|
||||
s."Name" as name,
|
||||
s."Group 1" as assigned_zone,
|
||||
STRING_AGG(i.group_1, ',') as actual_zones,
|
||||
ST_AsGeoJSON(s.geometry)::json AS geometry
|
||||
FROM %s s
|
||||
LEFT JOIN %s i ON ST_Within(s.geometry, i.geom)
|
||||
WHERE s."MapProjectID" = ?
|
||||
GROUP BY s.gid, s.id, s."Name", s."Group 1", s.geometry
|
||||
`, table, infoTable)
|
||||
|
||||
err := db.Raw(query, mapID).Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
qcResults := make([]ZoneContainmentResult, 0, len(results))
|
||||
|
||||
for _, site := range results {
|
||||
result := ZoneContainmentResult{
|
||||
ElementID: site.ID,
|
||||
ElementType: "site",
|
||||
ElementName: "",
|
||||
AssignedZone: site.AssignedZone,
|
||||
Geometry: parseGeometryToMap(site.Geometry),
|
||||
}
|
||||
|
||||
if site.Name != nil {
|
||||
result.ElementName = *site.Name
|
||||
}
|
||||
|
||||
// Parse actual zones
|
||||
if site.ActualZones != "" {
|
||||
result.ActualZones = splitZones(site.ActualZones)
|
||||
} else {
|
||||
result.ActualZones = []string{}
|
||||
}
|
||||
|
||||
// Check validity
|
||||
if site.AssignedZone == nil || *site.AssignedZone == "" {
|
||||
result.IsValid = false
|
||||
result.ErrorMessage = "Element has no assigned zone (NULL or blank)"
|
||||
} else if len(result.ActualZones) == 0 {
|
||||
result.IsValid = false
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but not found in any zone", *site.AssignedZone)
|
||||
} else {
|
||||
// Check if assigned zone is in actual zones
|
||||
result.IsValid = false
|
||||
for _, actualZone := range result.ActualZones {
|
||||
if actualZone == *site.AssignedZone {
|
||||
result.IsValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !result.IsValid {
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but found in: %v", *site.AssignedZone, result.ActualZones)
|
||||
}
|
||||
}
|
||||
|
||||
qcResults = append(qcResults, result)
|
||||
}
|
||||
|
||||
return qcResults, nil
|
||||
}
|
||||
|
||||
// checkPoleZones validates poles against their assigned zones using PostGIS
|
||||
func checkPoleZones(db *gorm.DB, mapID, schema string, zones []models.InfoGeoJSON) ([]ZoneContainmentResult, error) {
|
||||
type PoleZoneCheck struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Name *string `gorm:"column:name"`
|
||||
AssignedZone *string `gorm:"column:assigned_zone"`
|
||||
ActualZones string `gorm:"column:actual_zones"`
|
||||
Geometry json.RawMessage `gorm:"column:geometry"`
|
||||
}
|
||||
|
||||
var results []PoleZoneCheck
|
||||
table := fmt.Sprintf("%s.poles", schema)
|
||||
infoTable := fmt.Sprintf("%s.info", schema)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(p.id, p.gid) as id,
|
||||
p.name,
|
||||
p.group1 as assigned_zone,
|
||||
STRING_AGG(i.group_1, ',') as actual_zones,
|
||||
ST_AsGeoJSON(ST_Transform(p.geom, 4326))::json AS geometry
|
||||
FROM %s p
|
||||
LEFT JOIN %s i ON ST_Within(ST_Transform(p.geom, 4326), i.geom)
|
||||
WHERE p.mapprojectid = ?
|
||||
GROUP BY p.gid, p.id, p.name, p.group1, p.geom
|
||||
`, table, infoTable)
|
||||
|
||||
err := db.Raw(query, mapID).Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
qcResults := make([]ZoneContainmentResult, 0, len(results))
|
||||
|
||||
for _, pole := range results {
|
||||
result := ZoneContainmentResult{
|
||||
ElementID: pole.ID,
|
||||
ElementType: "pole",
|
||||
ElementName: "",
|
||||
AssignedZone: pole.AssignedZone,
|
||||
Geometry: parseGeometryToMap(pole.Geometry),
|
||||
}
|
||||
|
||||
if pole.Name != nil {
|
||||
result.ElementName = *pole.Name
|
||||
}
|
||||
|
||||
// Parse actual zones
|
||||
if pole.ActualZones != "" {
|
||||
result.ActualZones = splitZones(pole.ActualZones)
|
||||
} else {
|
||||
result.ActualZones = []string{}
|
||||
}
|
||||
|
||||
// Check validity
|
||||
if pole.AssignedZone == nil || *pole.AssignedZone == "" {
|
||||
result.IsValid = false
|
||||
result.ErrorMessage = "Element has no assigned zone (NULL or blank)"
|
||||
} else if len(result.ActualZones) == 0 {
|
||||
result.IsValid = false
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but not found in any zone", *pole.AssignedZone)
|
||||
} else {
|
||||
// Check if assigned zone is in actual zones
|
||||
result.IsValid = false
|
||||
for _, actualZone := range result.ActualZones {
|
||||
if actualZone == *pole.AssignedZone {
|
||||
result.IsValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !result.IsValid {
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but found in: %v", *pole.AssignedZone, result.ActualZones)
|
||||
}
|
||||
}
|
||||
|
||||
qcResults = append(qcResults, result)
|
||||
}
|
||||
|
||||
return qcResults, nil
|
||||
}
|
||||
|
||||
// checkAccessPointZones validates access points against their assigned zones using PostGIS
|
||||
func checkAccessPointZones(db *gorm.DB, mapID, schema string, zones []models.InfoGeoJSON) ([]ZoneContainmentResult, error) {
|
||||
type AccessPointZoneCheck struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Name *string `gorm:"column:name"`
|
||||
AssignedZone *string `gorm:"column:assigned_zone"`
|
||||
ActualZones string `gorm:"column:actual_zones"`
|
||||
Geometry json.RawMessage `gorm:"column:geometry"`
|
||||
}
|
||||
|
||||
var results []AccessPointZoneCheck
|
||||
table := fmt.Sprintf("%s.access_points", schema)
|
||||
infoTable := fmt.Sprintf("%s.info", schema)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COALESCE(ap.id, ap.gid) as id,
|
||||
ap.name,
|
||||
ap.group1 as assigned_zone,
|
||||
STRING_AGG(i.group_1, ',') as actual_zones,
|
||||
ST_AsGeoJSON(ST_Transform(ap.geom, 4326))::json AS geometry
|
||||
FROM %s ap
|
||||
LEFT JOIN %s i ON ST_Within(ST_Transform(ap.geom, 4326), i.geom)
|
||||
WHERE ap.mapprojectid = ?
|
||||
GROUP BY ap.gid, ap.id, ap.name, ap.group1, ap.geom
|
||||
`, table, infoTable)
|
||||
|
||||
err := db.Raw(query, mapID).Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
qcResults := make([]ZoneContainmentResult, 0, len(results))
|
||||
|
||||
for _, ap := range results {
|
||||
result := ZoneContainmentResult{
|
||||
ElementID: ap.ID,
|
||||
ElementType: "access_point",
|
||||
ElementName: "",
|
||||
AssignedZone: ap.AssignedZone,
|
||||
Geometry: parseGeometryToMap(ap.Geometry),
|
||||
}
|
||||
|
||||
if ap.Name != nil {
|
||||
result.ElementName = *ap.Name
|
||||
}
|
||||
|
||||
// Parse actual zones
|
||||
if ap.ActualZones != "" {
|
||||
result.ActualZones = splitZones(ap.ActualZones)
|
||||
} else {
|
||||
result.ActualZones = []string{}
|
||||
}
|
||||
|
||||
// Check validity
|
||||
if ap.AssignedZone == nil || *ap.AssignedZone == "" {
|
||||
result.IsValid = false
|
||||
result.ErrorMessage = "Element has no assigned zone (NULL or blank)"
|
||||
} else if len(result.ActualZones) == 0 {
|
||||
result.IsValid = false
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but not found in any zone", *ap.AssignedZone)
|
||||
} else {
|
||||
// Check if assigned zone is in actual zones
|
||||
result.IsValid = false
|
||||
for _, actualZone := range result.ActualZones {
|
||||
if actualZone == *ap.AssignedZone {
|
||||
result.IsValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !result.IsValid {
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but found in: %v", *ap.AssignedZone, result.ActualZones)
|
||||
}
|
||||
}
|
||||
|
||||
qcResults = append(qcResults, result)
|
||||
}
|
||||
|
||||
return qcResults, nil
|
||||
}
|
||||
|
||||
// validateElementZone checks if an element is within its assigned zone
|
||||
// For segments: isLineString=true, allows partial intersection
|
||||
// For points: isLineString=false, requires point to be within zone
|
||||
func validateElementZone(elementID int, elementType, elementName string, assignedZone *string, geometry json.RawMessage, zones []models.InfoGeoJSON, isLineString bool) ZoneContainmentResult {
|
||||
result := ZoneContainmentResult{
|
||||
ElementID: elementID,
|
||||
ElementType: elementType,
|
||||
ElementName: elementName,
|
||||
AssignedZone: assignedZone,
|
||||
IsValid: false,
|
||||
ActualZones: []string{},
|
||||
}
|
||||
|
||||
// Parse geometry
|
||||
var geomMap map[string]interface{}
|
||||
if err := json.Unmarshal(geometry, &geomMap); err != nil {
|
||||
result.ErrorMessage = "Failed to parse geometry"
|
||||
return result
|
||||
}
|
||||
result.Geometry = geomMap
|
||||
|
||||
// Check if assigned zone is NULL or empty - this is INVALID
|
||||
if assignedZone == nil || *assignedZone == "" {
|
||||
result.ErrorMessage = "Element has no assigned zone (NULL or blank)"
|
||||
return result
|
||||
}
|
||||
|
||||
// Find which zones contain this element
|
||||
for _, zone := range zones {
|
||||
if zone.Group1 == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if isLineString {
|
||||
// For segments (LineStrings): check if ANY part intersects with the zone
|
||||
if geometryIntersectsZone(geomMap, zone.Geometry) {
|
||||
result.ActualZones = append(result.ActualZones, *zone.Group1)
|
||||
}
|
||||
} else {
|
||||
// For points: check if point is within the zone
|
||||
if pointWithinZone(geomMap, zone.Geometry) {
|
||||
result.ActualZones = append(result.ActualZones, *zone.Group1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate: assigned zone must be in the list of actual zones
|
||||
for _, actualZone := range result.ActualZones {
|
||||
if actualZone == *assignedZone {
|
||||
result.IsValid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Element is not in its assigned zone
|
||||
if len(result.ActualZones) == 0 {
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but not found in any zone", *assignedZone)
|
||||
} else {
|
||||
result.ErrorMessage = fmt.Sprintf("Element assigned to '%s' but found in: %v", *assignedZone, result.ActualZones)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// geometryIntersectsZone checks if a LineString geometry intersects with a zone polygon
|
||||
func geometryIntersectsZone(lineGeom map[string]interface{}, zoneGeometry json.RawMessage) bool {
|
||||
var zonePoly map[string]interface{}
|
||||
if err := json.Unmarshal(zoneGeometry, &zonePoly); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get line coordinates
|
||||
lineCoords, ok := lineGeom["coordinates"].([]interface{})
|
||||
if !ok || len(lineCoords) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get polygon coordinates (first ring is outer boundary)
|
||||
polyCoords, ok := zonePoly["coordinates"].([]interface{})
|
||||
if !ok || len(polyCoords) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
outerRing, ok := polyCoords[0].([]interface{})
|
||||
if !ok || len(outerRing) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if ANY point of the line is within the polygon
|
||||
for _, coordInterface := range lineCoords {
|
||||
coord, ok := coordInterface.([]interface{})
|
||||
if !ok || len(coord) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
lng, ok1 := coord[0].(float64)
|
||||
lat, ok2 := coord[1].(float64)
|
||||
if !ok1 || !ok2 {
|
||||
continue
|
||||
}
|
||||
|
||||
if pointInPolygon(lng, lat, outerRing) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// pointWithinZone checks if a Point geometry is within a zone polygon
|
||||
func pointWithinZone(pointGeom map[string]interface{}, zoneGeometry json.RawMessage) bool {
|
||||
var zonePoly map[string]interface{}
|
||||
if err := json.Unmarshal(zoneGeometry, &zonePoly); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get point coordinates
|
||||
pointCoords, ok := pointGeom["coordinates"].([]interface{})
|
||||
if !ok || len(pointCoords) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
lng, ok1 := pointCoords[0].(float64)
|
||||
lat, ok2 := pointCoords[1].(float64)
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get polygon coordinates
|
||||
polyCoords, ok := zonePoly["coordinates"].([]interface{})
|
||||
if !ok || len(polyCoords) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
outerRing, ok := polyCoords[0].([]interface{})
|
||||
if !ok || len(outerRing) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return pointInPolygon(lng, lat, outerRing)
|
||||
}
|
||||
|
||||
// pointInPolygon uses ray casting algorithm to determine if point is inside polygon
|
||||
func pointInPolygon(lng, lat float64, ring []interface{}) bool {
|
||||
inside := false
|
||||
j := len(ring) - 1
|
||||
|
||||
for i := 0; i < len(ring); i++ {
|
||||
coord, ok := ring[i].([]interface{})
|
||||
if !ok || len(coord) < 2 {
|
||||
continue
|
||||
}
|
||||
coordJ, ok := ring[j].([]interface{})
|
||||
if !ok || len(coordJ) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
xi, ok1 := coord[0].(float64)
|
||||
yi, ok2 := coord[1].(float64)
|
||||
xj, ok3 := coordJ[0].(float64)
|
||||
yj, ok4 := coordJ[1].(float64)
|
||||
|
||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
||||
continue
|
||||
}
|
||||
|
||||
intersect := ((yi > lat) != (yj > lat)) && (lng < (xj-xi)*(lat-yi)/(yj-yi)+xi)
|
||||
if intersect {
|
||||
inside = !inside
|
||||
}
|
||||
|
||||
j = i
|
||||
}
|
||||
|
||||
return inside
|
||||
}
|
||||
|
||||
// GetInvalidZoneContainment returns only elements that failed the check
|
||||
func GetInvalidZoneContainment(db *gorm.DB, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol string) ([]ZoneContainmentResult, error) {
|
||||
summary, err := CheckZoneContainment(db, mapID, zone, schema, segmentTable, mapIDCol, zoneCol, idCol, qcFlagCol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var invalid []ZoneContainmentResult
|
||||
for _, result := range summary.Results {
|
||||
if !result.IsValid {
|
||||
invalid = append(invalid, result)
|
||||
}
|
||||
}
|
||||
|
||||
return invalid, nil
|
||||
}
|
||||
|
||||
// UpdateZoneContainmentFlags updates QC flags for invalid segments
|
||||
func UpdateZoneContainmentFlags(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, "zone_containment_invalid").Error
|
||||
}
|
||||
|
||||
// Helper function to get string pointer
|
||||
func getStringPointer(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
ReferrerUrl=C:\Users\AlexanderHall\Downloads\Auto_LLD-QC-main.zip
|
||||
Reference in New Issue
Block a user