Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add endpoint to retrieve route ways #195

Merged
merged 4 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ ignoreWords:
- Parens
- pgconn
- pgrouting
- pgtype
- PGUSER
- pgxpool
- pkey
Expand All @@ -56,6 +57,7 @@ ignoreWords:
- testcontainers
- transporthttp
- trgm
- unnest
- unparam
ignorePaths:
- "*.svg"
Expand Down
87 changes: 87 additions & 0 deletions server/api/swagger/ecomap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,42 @@ paths:
$ref: "#/components/schemas/Error"
500:
$ref: "#/components/responses/InternalServerError"
/routes/{routeId}/ways:
get:
summary: Get ways of a route.
operationId: getRouteWays
description: Returns the ways of the route with the specified identifier.
tags:
- Route
security:
- BearerAuth: [wasteOperator, manager]
parameters:
- $ref: "#/components/parameters/RouteIdPathParam"
responses:
200:
description: Successful operation.
content:
application/json:
schema:
$ref: "#/components/schemas/GeoJSONFeatureCollectionLineString"
400:
description: Invalid route ID.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
401:
$ref: "#/components/responses/Unauthorized"
403:
$ref: "#/components/responses/Forbidden"
404:
description: Route not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
500:
$ref: "#/components/responses/InternalServerError"

/routes/{routeId}/containers:
get:
Expand Down Expand Up @@ -2391,6 +2427,26 @@ components:
items:
type: number
format: double
GeoJSONGeometryLineString:
type: object
description: Geometry line string using the EPSG:4326 coordinate system.
required:
- type
- coordinates
properties:
type:
type: string
enum:
- LineString
coordinates:
type: array
items:
type: array
maxItems: 2
minItems: 2
items:
type: number
format: double
GeoJSONFeatureProperties:
type: object
description: GeoJSON feature properties.
Expand All @@ -2417,6 +2473,37 @@ components:
$ref: "#/components/schemas/GeoJSONGeometryPoint"
properties:
$ref: "#/components/schemas/GeoJSONFeatureProperties"
GeoJSONFeatureLineString:
type: object
description: GeoJSON feature with geometry line string.
required:
- type
- geometry
- properties
properties:
type:
type: string
enum:
- Feature
geometry:
$ref: "#/components/schemas/GeoJSONGeometryLineString"
properties:
$ref: "#/components/schemas/GeoJSONFeatureProperties"
GeoJSONFeatureCollectionLineString:
type: object
description: GeoJSON feature collection with geometry line string.
required:
- type
- features
properties:
type:
type: string
enum:
- FeatureCollection
features:
type: array
items:
$ref: "#/components/schemas/GeoJSONFeatureLineString"

EmployeeRole:
type: string
Expand Down
156 changes: 156 additions & 0 deletions server/internal/service/route.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
descriptionFailedGetRouteByID = "service: failed to get route by id"
descriptionFailedPatchRoute = "service: failed to patch route"
descriptionFailedDeleteRouteByID = "service: failed to delete route by id"
descriptionFailedGetRouteRoads = "service: failed to get route roads"
)

// CreateRoute creates a new route with the specified data.
Expand Down Expand Up @@ -217,3 +218,158 @@ func (s *service) DeleteRouteByID(ctx context.Context, id uuid.UUID) (domain.Rou

return route, nil
}

// GetRouteRoads returns the route roads using the TSP and A* algorithms. The route starts at the departure warehouse,
// passes through all the route containers, and before terminating at the arrival warehouse, passes through the nearest
// landfill to the arrival warehouse.
func (s *service) GetRouteRoads(ctx context.Context, id uuid.UUID) (domain.GeoJSON, error) {
logAttrs := []any{
slog.String(logging.ServiceMethod, "GetRouteRoads"),
slog.String(logging.RouteID, id.String()),
}

var roadsGeometry []domain.GeoJSONGeometryLineString

err := s.readOnlyTx(ctx, func(tx pgx.Tx) error {
route, err := s.store.GetRouteByID(ctx, tx, id)
if err != nil {
return err
}

routeContainerRoads, err := s.store.GetContainerRoadsByRouteID(ctx, tx, route.ID)
if err != nil {
return err
}

// Early return when the route does not contain any containers associated.
if len(routeContainerRoads) == 0 {
return nil
}

arrivalWarehouse, err := s.store.GetWarehouseByID(ctx, tx, route.ArrivalWarehouseID)
if err != nil {
return err
}

// Get essential roads.
var departureRoad *domain.Road
tempDepartureRoad, err := s.store.GetRoadByWarehouseID(ctx, tx, route.DepartureWarehouseID)
if err != nil {
if !errors.Is(err, domain.ErrRoadNotFound) {
return err
}
} else {
departureRoad = &tempDepartureRoad
}

var arrivalRoad *domain.Road
tempArrivalRoad, err := s.store.GetRoadByWarehouseID(ctx, tx, route.ArrivalWarehouseID)
if err != nil {
if !errors.Is(err, domain.ErrRoadNotFound) {
return err
}
} else {
arrivalRoad = &tempArrivalRoad
}

var arrivalWarehouseGeometry domain.GeoJSONGeometryPoint
if feature, ok := arrivalWarehouse.GeoJSON.(domain.GeoJSONFeature); ok {
if g, ok := feature.Geometry.(domain.GeoJSONGeometryPoint); ok {
arrivalWarehouseGeometry = g
}
}

var landfillID *uuid.UUID
landfill, err := s.store.GetLandfillClosestGeometry(ctx, tx, arrivalWarehouseGeometry)
if err != nil {
if !errors.Is(err, domain.ErrLandfillNotFound) {
return err
}
} else {
landfillID = &landfill.ID
}

var landfillRoad *domain.Road
if landfillID != nil {
tempLandfillRoad, err := s.store.GetRoadByLandfillID(ctx, tx, *landfillID)
if err != nil {
if !errors.Is(err, domain.ErrRoadNotFound) {
return err
}
} else {
landfillRoad = &tempLandfillRoad
}
}

// Compute the TSP for the route container vertices, starting at the departure warehouse and ending at the
// closest landfill to the arrival warehouse.
vertexIDs := make([]int, 0, len(routeContainerRoads)+2)

for _, road := range routeContainerRoads {
if road.Source == nil {
continue
}

vertexIDs = append(vertexIDs, *road.Source)
}
if departureRoad != nil && departureRoad.Source != nil {
vertexIDs = append(vertexIDs, *departureRoad.Source)
}
if landfillRoad != nil && landfillRoad.Source != nil {
vertexIDs = append(vertexIDs, *landfillRoad.Source)
}

departureVertexID := vertexIDs[0]
if departureRoad != nil && departureRoad.Source != nil {
departureVertexID = *departureRoad.Source
}

landfillVertexID := vertexIDs[0]
if landfillRoad != nil && landfillRoad.Source != nil {
landfillVertexID = *landfillRoad.Source
}

seqVertexIDs, err := s.store.GetRoadVerticesTSP(ctx, tx, vertexIDs, departureVertexID, landfillVertexID, true)
if err != nil {
return err
}

arrivalVertexID := departureVertexID
if arrivalRoad != nil && arrivalRoad.Source != nil {
arrivalVertexID = *arrivalRoad.Source
}

// Replace the last vertex with the last actual point of the route.
// This is a blind replacement because the last vertex is always the same as the first.
if len(seqVertexIDs) != 0 {
seqVertexIDs[len(seqVertexIDs)-1] = arrivalVertexID
}

// Compute the roads based on the sequential vertices.
roadsGeometry, err = s.store.GetRoadsGeometryAStar(ctx, tx, seqVertexIDs, true)
if err != nil {
return err
}

return nil
})
if err != nil {
switch {
case errors.Is(err, domain.ErrRouteNotFound):
return nil, logInfoAndWrapError(ctx, err, descriptionFailedGetRouteRoads, logAttrs...)
default:
return nil, logAndWrapError(ctx, err, descriptionFailedGetRouteRoads, logAttrs...)
}
}

geoJSONFeature := make([]domain.GeoJSONFeature, len(roadsGeometry))
for i, geometry := range roadsGeometry {
geoJSONFeature[i] = domain.GeoJSONFeature{
Geometry: geometry,
}
}

return domain.GeoJSONFeatureCollection{
Features: geoJSONFeature,
}, nil
}
6 changes: 6 additions & 0 deletions server/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type Store interface {
CreateLandfill(ctx context.Context, tx pgx.Tx, editableLandfill domain.EditableLandfill, roadID, municipalityID *int) (uuid.UUID, error)
ListLandfills(ctx context.Context, tx pgx.Tx, filter domain.LandfillsPaginatedFilter) (domain.PaginatedResponse[domain.Landfill], error)
GetLandfillByID(ctx context.Context, tx pgx.Tx, id uuid.UUID) (domain.Landfill, error)
GetLandfillClosestGeometry(ctx context.Context, tx pgx.Tx, geometry domain.GeoJSONGeometryPoint) (domain.Landfill, error)
PatchLandfill(ctx context.Context, tx pgx.Tx, id uuid.UUID, editableLandfill domain.EditableLandfillPatch, roadID, municipalityID *int) error
DeleteLandfillByID(ctx context.Context, tx pgx.Tx, id uuid.UUID) error

Expand All @@ -95,6 +96,11 @@ type Store interface {
DeleteRouteEmployee(ctx context.Context, tx pgx.Tx, routeID, employeeID uuid.UUID) error

GetRoadByGeometry(ctx context.Context, tx pgx.Tx, geometry domain.GeoJSONGeometryPoint) (domain.Road, error)
GetRoadByWarehouseID(ctx context.Context, tx pgx.Tx, warehouseID uuid.UUID) (domain.Road, error)
GetRoadByLandfillID(ctx context.Context, tx pgx.Tx, landfillID uuid.UUID) (domain.Road, error)
GetContainerRoadsByRouteID(ctx context.Context, tx pgx.Tx, routeID uuid.UUID) ([]domain.Road, error)
GetRoadVerticesTSP(ctx context.Context, tx pgx.Tx, vertexIDs []int, startVertexID, endVertexID int, directed bool) ([]int, error)
GetRoadsGeometryAStar(ctx context.Context, tx pgx.Tx, seqVertexIDs []int, directed bool) ([]domain.GeoJSONGeometryLineString, error)

GetMunicipalityByGeometry(ctx context.Context, tx pgx.Tx, geometry domain.GeoJSONGeometryPoint) (domain.Municipality, error)

Expand Down
31 changes: 31 additions & 0 deletions server/internal/store/landfill.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package store

import (
"context"
"encoding/json"
"errors"
"fmt"

Expand Down Expand Up @@ -134,6 +135,36 @@ func (s *store) GetLandfillByID(ctx context.Context, tx pgx.Tx, id uuid.UUID) (d
return landfill, nil
}

// GetLandfillClosestGeometry executes a query to return the landfill that is closest to the given geometry.
func (s *store) GetLandfillClosestGeometry(ctx context.Context, tx pgx.Tx, geometry domain.GeoJSONGeometryPoint) (domain.Landfill, error) {
geoJSON, err := json.Marshal(geometry)
if err != nil {
return domain.Landfill{}, fmt.Errorf("%s: %w", descriptionFailedMarshalGeoJSON, err)
}

row := tx.QueryRow(ctx, `
SELECT l.id, ST_AsGeoJSON(l.geom)::jsonb, rn.osm_name, m.name, l.created_at, l.modified_at
FROM landfills AS l
LEFT JOIN road_network AS rn ON l.road_id = rn.id
LEFT JOIN municipalities AS m ON l.municipality_id = m.id
ORDER BY ST_Distance(l.geom, ST_GeomFromGeoJSON($1))
LIMIT 1
`,
string(geoJSON),
)

landfill, err := getLandfillFromRow(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Landfill{}, fmt.Errorf("%s: %w", descriptionFailedScanRow, domain.ErrLandfillNotFound)
}

return domain.Landfill{}, fmt.Errorf("%s: %w", descriptionFailedScanRow, err)
}

return landfill, nil
}

// PatchLandfill executes a query to patch an landfill with the specified identifier and data.
func (s *store) PatchLandfill(ctx context.Context, tx pgx.Tx, id uuid.UUID, editableLandfill domain.EditableLandfillPatch, roadID, municipalityID *int) error {
var geoJSON []byte
Expand Down
Loading