dragndrop_hld/oldqc/Backend/qc/zone_containment.go
alex 12407b74e4 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>
2025-12-04 13:43:57 -07:00

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
}