Skip to content

Commit

Permalink
Add Region model with migrations
Browse files Browse the repository at this point in the history
The dashboard will need also some bigger areas than the current "parking
areas".  Let's call these regions.

Add a new model Region with name, geometry and capacity_estimate fields.

Add a region field to Parking model so that it's fast to find parkings
of a region.

The capacity estimates of the regions will be calculated based on the
capacity estimates of the parking areas.  Add some methods to
ParkingAreaQuerySet to help calculating these estimates.
  • Loading branch information
suutari-ai committed Feb 12, 2018
1 parent f47f4ac commit 25b0cdb
Show file tree
Hide file tree
Showing 9 changed files with 345 additions and 6 deletions.
7 changes: 6 additions & 1 deletion parkings/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.contrib.gis.admin import OSMGeoAdmin

from .models import Operator, Parking, ParkingArea, ParkingTerminal
from .models import Operator, Parking, ParkingArea, ParkingTerminal, Region


class OperatorAdmin(admin.ModelAdmin):
Expand All @@ -18,6 +18,11 @@ class ParkingAdmin(OSMGeoAdmin):
ordering = ('-created_at',)


@admin.register(Region)
class RegionAdmin(OSMGeoAdmin):
ordering = ('name',)


class ParkingAreaAdmin(OSMGeoAdmin):
ordering = ('origin_id',)

Expand Down
48 changes: 48 additions & 0 deletions parkings/migrations/0017_region.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-09-20 02:22
from __future__ import unicode_literals

import uuid

import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('parkings', '0016_normalized_reg_num_noblank'),
]

operations = [
migrations.CreateModel(
name='Region',
fields=[
('created_at', models.DateTimeField(
auto_now_add=True, verbose_name='time created')),
('modified_at', models.DateTimeField(
auto_now=True, verbose_name='time modified')),
('id', models.UUIDField(
default=uuid.uuid4, editable=False,
primary_key=True, serialize=False)),
('name', models.CharField(
blank=True, max_length=200, verbose_name='name')),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(
srid=3879, verbose_name='geometry')),
],
options={
'verbose_name_plural': 'regions',
'verbose_name': 'region',
},
),
migrations.AddField(
model_name='parkingarea',
name='region',
field=models.ForeignKey(
blank=True, null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='areas',
to='parkings.Region', verbose_name='region'),
),
]
29 changes: 29 additions & 0 deletions parkings/migrations/0018_parking_region.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
('parkings', '0017_region'),
]

operations = [
migrations.RemoveField(
model_name='parkingarea',
name='region', ),
migrations.AddField(
model_name='parking',
name='region',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='parkings',
to='parkings.Region',
verbose_name='region')),
migrations.AddField(
model_name='region',
name='capacity_estimate',
field=models.PositiveIntegerField(
blank=True, null=True, verbose_name='capacity estimate')),
]
2 changes: 2 additions & 0 deletions parkings/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
from .parking import Parking, ParkingQuerySet
from .parking_area import ParkingArea
from .parking_terminal import ParkingTerminal
from .region import Region

__all__ = [
'Operator',
'Parking',
'ParkingArea',
'ParkingTerminal',
'ParkingQuerySet',
'Region',
]
24 changes: 19 additions & 5 deletions parkings/models/parking.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from parkings.models.parking_area import ParkingArea

from .parking_terminal import ParkingTerminal
from .region import Region

Q = models.Q

Expand Down Expand Up @@ -44,6 +45,10 @@ class Parking(TimestampedModelMixin, UUIDPrimaryKeyMixin):
VALID = 'valid'
NOT_VALID = 'not_valid'

region = models.ForeignKey(
Region, null=True, blank=True, on_delete=models.SET_NULL,
related_name='parkings', verbose_name=_("region"),
)
parking_area = models.ForeignKey(
ParkingArea, on_delete=models.SET_NULL, verbose_name=_("parking area"), related_name='parkings', null=True,
blank=True,
Expand Down Expand Up @@ -106,6 +111,11 @@ def get_state(self):

return Parking.VALID

def get_region(self):
if not self.location:
return None
return Region.objects.filter(geom__intersects=self.location).first()

def get_closest_area(self, max_distance=50):
if self.location:
location = self.location
Expand All @@ -117,20 +127,24 @@ def get_closest_area(self, max_distance=50):
).filter(distance__lte=max_distance).order_by('distance').first()
return closest_area

def save(self, *args, **kwargs):
def save(self, update_fields=None, *args, **kwargs):
if not self.terminal and self.terminal_number:
self.terminal = ParkingTerminal.objects.filter(
number=_try_cast_int(self.terminal_number)).first()

if self.terminal and not self.location:
self.location = self.terminal.location

self.parking_area = self.get_closest_area()
if update_fields is None or 'region' in update_fields:
self.region = self.get_region()
if update_fields is None or 'parking_area' in update_fields:
self.parking_area = self.get_closest_area()

self.normalized_reg_num = (
self.normalize_reg_num(self.registration_number))
if update_fields is None or 'normalized_reg_num' in update_fields:
self.normalized_reg_num = (
self.normalize_reg_num(self.registration_number))

super(Parking, self).save(*args, **kwargs)
super(Parking, self).save(update_fields=update_fields, *args, **kwargs)

@classmethod
def normalize_reg_num(cls, registration_number):
Expand Down
86 changes: 86 additions & 0 deletions parkings/models/parking_area.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,68 @@
from django.contrib.gis.db import models
from django.contrib.gis.db.models.functions import Area
from django.db.models import Func, Sum
from django.utils.translation import ugettext_lazy as _

from parkings.models.mixins import TimestampedModelMixin, UUIDPrimaryKeyMixin

# Estimated number of parking spots per square meter (m^2)
PARKING_SPOTS_PER_SQ_M = 0.07328


class Round(Func):
"""
Function for rounding in SQL using Django ORM.
"""
function = 'ROUND'


class ParkingAreaQuerySet(models.QuerySet):
def inside_region(self, region):
"""
Filter to parking areas which are inside given region.
:type region: .region.Region
:rtype: ParkingAreaQuerySet
"""
return self.filter(geom__coveredby=region.geom)

def intersecting_region(self, region):
"""
Filter to parking areas which intersect with given region.
:type region: .region.Region
:rtype: ParkingAreaQuerySet
"""
return self.filter(geom__intersects=region.geom)

@property
def total_estimated_capacity(self):
"""
Estimated number of parking spots in this queryset.
This is the sum of `parking_area.estimated_capacity` of each
`parking_area` in this queryset. The value is calculated in the
database to make it faster compared to calculating the sum in
Python.
:rtype: int
"""
defined_qs = self.exclude(capacity_estimate=None)
undefined_qs = self.filter(capacity_estimate=None)
total_defined = defined_qs.sum_of_capacity_estimates or 0
total_by_area = undefined_qs.estimate_total_capacity_by_areas()
return total_defined + total_by_area

@property
def sum_of_capacity_estimates(self):
return self.aggregate(val=Sum('capacity_estimate'))['val']

def estimate_total_capacity_by_areas(self):
spots = (
self.annotate(spots=Round(PARKING_SPOTS_PER_SQ_M * Area('geom')))
.aggregate(val=Sum('spots')))['val']
return int(spots.sq_m) if spots else 0


class ParkingArea(TimestampedModelMixin, UUIDPrimaryKeyMixin):
# This is for whatever ID the external system that this parking lot was
Expand Down Expand Up @@ -34,9 +94,35 @@ class ParkingArea(TimestampedModelMixin, UUIDPrimaryKeyMixin):
blank=True,
)

objects = ParkingAreaQuerySet.as_manager()

class Meta:
verbose_name = _('parking area')
verbose_name_plural = _('parking areas')

def __str__(self):
return 'Parking Area %s' % str(self.origin_id)

@property
def estimated_capacity(self):
"""
Estimated number of parking spots in this parking area.
If there is a capacity estimate specified for this area in the
`capacity_estimate` field, return it, otherwise return a
capacity estimate based on the area (m^2) of this parking area.
:rtype: int
"""
return (
self.capacity_estimate if self.capacity_estimate is not None
else self.estimate_capacity_by_area())

def estimate_capacity_by_area(self):
"""
Estimate number of parking spots in this parking area by m^2.
:rtype: int
"""
assert self.geom.srs.units == (1.0, 'metre')
return int(round(self.geom.area * PARKING_SPOTS_PER_SQ_M))
60 changes: 60 additions & 0 deletions parkings/models/region.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from django.contrib.gis.db import models as gis_models
from django.contrib.gis.db.models.functions import Intersection
from django.db import models
from django.db.models import Case, Count, Q, When
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _

from .mixins import TimestampedModelMixin, UUIDPrimaryKeyMixin
from .parking_area import ParkingArea


class RegionQuerySet(models.QuerySet):
def with_parking_count(self, at_time=None):
time = at_time if at_time else timezone.now()
valid_parkings_q = (
Q(parkings__time_start__lte=time) &
(Q(parkings__time_end__gte=time) | Q(parkings__time_end=None)))
return self.annotate(
parking_count=Count(Case(When(valid_parkings_q, then=1))))


class Region(TimestampedModelMixin, UUIDPrimaryKeyMixin):
name = models.CharField(max_length=200, blank=True, verbose_name=_("name"))
geom = gis_models.MultiPolygonField(srid=3879, verbose_name=_("geometry"))
capacity_estimate = models.PositiveIntegerField(
blank=True, null=True,
verbose_name=_("capacity estimate"),
)

objects = RegionQuerySet.as_manager()

class Meta:
verbose_name = _("region")
verbose_name_plural = _("regions")

def __str__(self):
return self.name or str(_("Unnamed region"))

def save(self, *args, **kwargs):
self.capacity_estimate = self.calculate_capacity_estimate()
super().save(*args, **kwargs)

def calculate_capacity_estimate(self):
"""
Calculate capacity estimate of this region from parking areas.
:rtype: int
"""
areas = ParkingArea.objects.all()
covered_areas = areas.inside_region(self)
partial_areas = (
areas.intersecting_region(self).exclude(pk__in=covered_areas)
.annotate(intsect=Intersection('geom', self.geom)))
sum_covered = covered_areas.total_estimated_capacity
sum_partials = sum(
# Scale the estimated capacity according to ratio of area of
# the intersection and total area
int(round(x.estimated_capacity * x.intsect.area / x.geom.area))
for x in partial_areas)
return sum_covered + sum_partials
22 changes: 22 additions & 0 deletions parkings/tests/test_parking_area_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import pytest

from parkings.models import ParkingArea


def test_str():
assert str(ParkingArea(origin_id='TEST_ID')) == 'Parking Area TEST_ID'


@pytest.mark.django_db
def test_estimated_capacity(parking_area):
parking_area.capacity_estimate = 123
assert parking_area.estimated_capacity == 123
parking_area.capacity_estimate = None
by_area = parking_area.estimate_capacity_by_area()
assert parking_area.estimated_capacity == by_area


@pytest.mark.django_db
def test_estimate_capacity_by_area(parking_area):
assert parking_area.estimate_capacity_by_area() == int(
round(parking_area.geom.area * 0.07328))
Loading

0 comments on commit 25b0cdb

Please sign in to comment.