diff --git a/parkings/admin.py b/parkings/admin.py index 4810466d..0cb8ae01 100644 --- a/parkings/admin.py +++ b/parkings/admin.py @@ -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): @@ -18,6 +18,11 @@ class ParkingAdmin(OSMGeoAdmin): ordering = ('-created_at',) +@admin.register(Region) +class RegionAdmin(OSMGeoAdmin): + ordering = ('name',) + + class ParkingAreaAdmin(OSMGeoAdmin): ordering = ('origin_id',) diff --git a/parkings/migrations/0017_region.py b/parkings/migrations/0017_region.py new file mode 100644 index 00000000..4439771c --- /dev/null +++ b/parkings/migrations/0017_region.py @@ -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'), + ), + ] diff --git a/parkings/migrations/0018_parking_region.py b/parkings/migrations/0018_parking_region.py new file mode 100644 index 00000000..3111a548 --- /dev/null +++ b/parkings/migrations/0018_parking_region.py @@ -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')), + ] diff --git a/parkings/models/__init__.py b/parkings/models/__init__.py index 9b0fddc7..a2ba0c57 100644 --- a/parkings/models/__init__.py +++ b/parkings/models/__init__.py @@ -2,6 +2,7 @@ from .parking import Parking, ParkingQuerySet from .parking_area import ParkingArea from .parking_terminal import ParkingTerminal +from .region import Region __all__ = [ 'Operator', @@ -9,4 +10,5 @@ 'ParkingArea', 'ParkingTerminal', 'ParkingQuerySet', + 'Region', ] diff --git a/parkings/models/parking.py b/parkings/models/parking.py index 42411062..6eacb892 100644 --- a/parkings/models/parking.py +++ b/parkings/models/parking.py @@ -9,6 +9,7 @@ from parkings.models.parking_area import ParkingArea from .parking_terminal import ParkingTerminal +from .region import Region Q = models.Q @@ -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, @@ -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 @@ -117,7 +127,7 @@ 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() @@ -125,12 +135,16 @@ def save(self, *args, **kwargs): 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): diff --git a/parkings/models/parking_area.py b/parkings/models/parking_area.py index c096dd02..f6ca46d5 100644 --- a/parkings/models/parking_area.py +++ b/parkings/models/parking_area.py @@ -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 @@ -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)) diff --git a/parkings/models/region.py b/parkings/models/region.py new file mode 100644 index 00000000..7f35ed5b --- /dev/null +++ b/parkings/models/region.py @@ -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 diff --git a/parkings/tests/test_parking_area_model.py b/parkings/tests/test_parking_area_model.py new file mode 100644 index 00000000..9622e311 --- /dev/null +++ b/parkings/tests/test_parking_area_model.py @@ -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)) diff --git a/parkings/tests/test_region_model.py b/parkings/tests/test_region_model.py new file mode 100644 index 00000000..82b73216 --- /dev/null +++ b/parkings/tests/test_region_model.py @@ -0,0 +1,73 @@ +import pytest +from django.contrib.gis.geos import MultiPolygon, Polygon + +from parkings.models import ParkingArea, Region + + +def test_str(): + assert str(Region(name='Foobba')) == 'Foobba' + assert str(Region(name=None)) == 'Unnamed region' + assert str(Region(name='')) == 'Unnamed region' + assert isinstance(str(Region()), str) + + +@pytest.mark.django_db +def test_calculate_capacity_estimate(): + """ + Test Region capacity estimate calculation. + + Construct the following geometries: + + - reg = Region to estimate + - pa1 = Parking area 1, capacity=3, inside reg + - pa2 = Parking area 2, capacity=4, inside reg + - pa3 = Parking area 3, capacity=15, 1/3 of area inside reg + - pa4 = Parking area 4, capacity=20, outside reg + + Total capacity should be 3 + 4 + (1/3)*15 = 12. + + Same as a graph:: + + +-------------------------------+ + | reg | + | +--------+ +---------+ +---------------------+ +-------... + | | pa1 | | pa2 | | | pa3 | | pa4 + | +--------+ +---------+ +---------------------+ +-------... + | capacity=3 capacity=4 | capacity=15 capacity=20 + | | + +-------------------------------+ + """ + def rect(topleft, width, height): + """ + Construct a rectangle as multipolygon. + """ + (ax, ay) = topleft # left = ax, top = ay + (bx, by) = (ax + width, ay + height) # right = bx, bottom = by + return MultiPolygon(Polygon( + [(ax, ay), (ax, by), (bx, by), (bx, ay), (ax, ay)])) + + reg = Region(geom=rect((0, 0), 100, 14)) + pa1 = ParkingArea(geom=rect((2, 2), 20, 10), capacity_estimate=3) + pa2 = ParkingArea(geom=rect((30, 2), 20, 10), capacity_estimate=4) + pa3 = ParkingArea(geom=rect((60, 2), 120, 10), capacity_estimate=15) + pa4 = ParkingArea(geom=rect((200, 2), 40, 10), capacity_estimate=20) + + assert reg.geom.area == 1400 + assert pa1.geom.area == 200 + assert pa2.geom.area == 200 + assert pa3.geom.area == 1200 + assert pa4.geom.area == 400 + + assert reg.geom.intersection(pa1.geom).area == pa1.geom.area + assert reg.geom.intersection(pa2.geom).area == pa2.geom.area + assert reg.geom.intersection(pa3.geom).area == pa3.geom.area / 3 + assert reg.geom.intersection(pa4.geom).area == 0 + + # Save them to the database + reg.save() + for (n, pa) in enumerate([pa1, pa2, pa3, pa4]): + pa.origin_id = 'PA-{}'.format(n) + pa.save() + + # And finally, check the result of the calculation is correct + assert reg.calculate_capacity_estimate() == 12