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>
744 lines
22 KiB
Go
744 lines
22 KiB
Go
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
|
|
}
|