Skip to content

Commit

Permalink
Merge pull request #20 from OpenGeoscience/simulations-pane
Browse files Browse the repository at this point in the history
Simulations pane
  • Loading branch information
annehaley authored Sep 21, 2023
2 parents f092580 + c992c06 commit d7ecc06
Show file tree
Hide file tree
Showing 21 changed files with 844 additions and 113 deletions.
7 changes: 6 additions & 1 deletion uvdat/core/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.contrib import admin

from uvdat.core.models import Chart, City, Dataset, NetworkNode, Region
from uvdat.core.models import Chart, City, Dataset, NetworkNode, Region, SimulationResult


class CityAdmin(admin.ModelAdmin):
Expand All @@ -26,8 +26,13 @@ class ChartAdmin(admin.ModelAdmin):
list_display = ['id', 'name']


class SimulationResultAdmin(admin.ModelAdmin):
list_display = ['id', 'simulation_id', 'input_args']


admin.site.register(City, CityAdmin)
admin.site.register(Dataset, DatasetAdmin)
admin.site.register(NetworkNode, NetworkNodeAdmin)
admin.site.register(Region, RegionAdmin)
admin.site.register(Chart, ChartAdmin)
admin.site.register(SimulationResult, SimulationResultAdmin)
55 changes: 55 additions & 0 deletions uvdat/core/migrations/0008_simulation_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Generated by Django 4.1 on 2023-09-21 17:18

from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields


class Migration(migrations.Migration):
dependencies = [
('core', '0007_charts'),
]

operations = [
migrations.CreateModel(
name='SimulationResult',
fields=[
(
'id',
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
('simulation_id', models.IntegerField()),
('input_args', models.JSONField(blank=True, null=True)),
('output_data', models.JSONField(blank=True, null=True)),
('error_message', models.TextField(blank=True, null=True)),
(
'city',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='simulation_results',
to='core.city',
),
),
],
),
migrations.AddConstraint(
model_name='simulationresult',
constraint=models.UniqueConstraint(
fields=('simulation_id', 'city', 'input_args'), name='unique_simulation_combination'
),
),
]
19 changes: 17 additions & 2 deletions uvdat/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from s3_file_field import S3FileField


class City(TimeStampedModel, models.Model):
class City(TimeStampedModel):
name = models.CharField(max_length=255, unique=True)
center = geo_models.PointField()
default_zoom = models.IntegerField(default=10)
Expand All @@ -13,7 +13,7 @@ class Meta:
verbose_name_plural = 'cities'


class Dataset(TimeStampedModel, models.Model):
class Dataset(TimeStampedModel):
name = models.CharField(max_length=255, unique=True)
description = models.TextField(null=True, blank=True)
city = models.ForeignKey(City, on_delete=models.CASCADE, related_name='datasets')
Expand Down Expand Up @@ -65,3 +65,18 @@ class Chart(models.Model):
metadata = models.JSONField(blank=True, null=True)
style = models.JSONField(blank=True, null=True)
clearable = models.BooleanField(default=False)


class SimulationResult(TimeStampedModel):
simulation_id = models.IntegerField()
city = models.ForeignKey(City, on_delete=models.CASCADE, related_name='simulation_results')
input_args = models.JSONField(blank=True, null=True)
output_data = models.JSONField(blank=True, null=True)
error_message = models.TextField(null=True, blank=True)

class Meta:
constraints = [
models.UniqueConstraint(
fields=['simulation_id', 'city', 'input_args'], name='unique_simulation_combination'
)
]
8 changes: 7 additions & 1 deletion uvdat/core/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from rest_framework import serializers

from uvdat.core.models import Chart, City, Dataset, NetworkNode
from uvdat.core.models import Chart, City, Dataset, NetworkNode, SimulationResult


class NetworkNodeSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -42,3 +42,9 @@ def get_center(self, obj):
class Meta:
model = City
fields = '__all__'


class SimulationResultSerializer(serializers.ModelSerializer):
class Meta:
model = SimulationResult
fields = '__all__'
2 changes: 1 addition & 1 deletion uvdat/core/tasks/conversion.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
import json
import os
from pathlib import Path
import tempfile
import zipfile
Expand Down
153 changes: 153 additions & 0 deletions uvdat/core/tasks/simulations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import json
from pathlib import Path
import re
import tempfile

from celery import shared_task
from django_large_image import tilesource
import large_image
import shapely

from uvdat.core.models import City, Dataset, SimulationResult
from uvdat.core.serializers import DatasetSerializer, SimulationResultSerializer


def get_network_node_elevations(network_nodes, elevation_dataset):
with tempfile.TemporaryDirectory() as tmp:
raster_path = Path(tmp, 'raster')
with open(raster_path, 'wb') as raster_file:
raster_file.write(elevation_dataset.raster_file.read())
source = large_image.open(raster_path)
data, data_format = source.getRegion(format='numpy')
data = data[:, :, 0]
metadata = tilesource.get_metadata(source)
source_bounds = metadata.get('bounds')

elevations = {}
for network_node in network_nodes:
# same logic as client-side tooltip
location = network_node.location
x_proportion = (location[0] - source_bounds.get('xmin')) / (
source_bounds.get('xmax') - source_bounds.get('xmin')
)
y_proportion = (location[1] - source_bounds.get('ymin')) / (
source_bounds.get('ymax') - source_bounds.get('ymin')
)
x_index = int(x_proportion * len(data[0]))
y_index = int(y_proportion * len(data))
elevations[network_node.id] = data[y_index, x_index]
return elevations


@shared_task
def flood_scenario_1(simulation_result_id, network_dataset, elevation_dataset, flood_dataset):
result = SimulationResult.objects.get(id=simulation_result_id)
try:
network_dataset = Dataset.objects.get(id=network_dataset)
elevation_dataset = Dataset.objects.get(id=elevation_dataset)
flood_dataset = Dataset.objects.get(id=flood_dataset)
except Dataset.DoesNotExist:
result.error_message = 'Dataset not found.'
result.save()
return None

if (
not network_dataset.network
or elevation_dataset.category != 'elevation'
or flood_dataset.category != 'flood'
):
result.error_message = 'Invalid dataset selected.'
result.save()
return None

disabled_nodes = []
network_nodes = network_dataset.network_nodes.all()
flood_geodata = json.loads(flood_dataset.geodata_file.open().read().decode())
flood_areas = [
shapely.geometry.shape(feature['geometry']) for feature in flood_geodata['features']
]
for network_node in network_nodes:
node_point = shapely.geometry.Point(*network_node.location)
if any(flood_area.contains(node_point) for flood_area in flood_areas):
disabled_nodes.append(network_node)

node_elevations = get_network_node_elevations(network_nodes, elevation_dataset)
disabled_nodes.sort(key=lambda n: node_elevations[n.id])

result.output_data = [n.id for n in disabled_nodes]
result.save()


AVAILABLE_SIMULATIONS = [
{
'id': 1,
'name': 'Flood Scenario 1',
'description': '''
Provide a network dataset, elevation dataset, and flood dataset
to determine which network nodes go out of service
when the target flood occurs.
''',
'output_type': 'node_failure_animation',
'func': flood_scenario_1,
'args': [
{
'name': 'network_dataset',
'type': Dataset,
'options_query': {'network': True},
},
{
'name': 'elevation_dataset',
'type': Dataset,
'options_query': {'category': 'elevation'},
},
{
'name': 'flood_dataset',
'type': Dataset,
'options_query': {'category': 'flood'},
},
],
}
]


def get_available_simulations(city_id: int):
sims = []
for available in AVAILABLE_SIMULATIONS:
available = available.copy()
available['description'] = re.sub(r'\n\s+', ' ', available['description'])
available['args'] = [
{
'name': a['name'],
'options': list(
DatasetSerializer(d).data
for d in a['type']
.objects.filter(
city__id=city_id,
**a['options_query'],
)
.all()
),
}
for a in available['args']
]
del available['func']
sims.append(available)
return sims


def run_simulation(simulation_id: int, city_id: int, **kwargs):
city = City.objects.get(id=city_id)
simulation_matches = [s for s in AVAILABLE_SIMULATIONS if s['id'] == simulation_id]
if len(simulation_matches) > 0:
sim_result, created = SimulationResult.objects.get_or_create(
simulation_id=simulation_id,
input_args=kwargs,
city=city,
)
sim_result.output_data = None
sim_result.save()

simulation = simulation_matches[0]
simulation['func'].delay(sim_result.id, **kwargs)
return SimulationResultSerializer(sim_result).data
return f"No simulation found with id {simulation_id}."
63 changes: 59 additions & 4 deletions uvdat/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet, ModelViewSet, mixins

from uvdat.core.models import Chart, City, Dataset, Region
from uvdat.core.models import Chart, City, Dataset, Region, SimulationResult
from uvdat.core.serializers import (
ChartSerializer,
CitySerializer,
DatasetSerializer,
NetworkNodeSerializer,
SimulationResultSerializer,
)
from uvdat.core.tasks.charts import add_gcc_chart_datum
from uvdat.core.tasks.conversion import convert_raw_data
from uvdat.core.tasks.networks import network_gcc
from uvdat.core.tasks.simulations import get_available_simulations, run_simulation


class CityViewSet(ModelViewSet):
Expand All @@ -29,10 +31,16 @@ class CityViewSet(ModelViewSet):


class DatasetViewSet(ModelViewSet, LargeImageFileDetailMixin):
queryset = Dataset.objects.all()
serializer_class = DatasetSerializer
FILE_FIELD_NAME = 'raster_file'

def get_queryset(self):
city_id = self.request.query_params.get('city')
if city_id:
return Dataset.objects.filter(city__id=city_id)
else:
return Dataset.objects.all()

@action(detail=True, methods=['get'])
def regions(self, request, **kwargs):
dataset = self.get_object()
Expand Down Expand Up @@ -158,8 +166,7 @@ def get_queryset(self, **kwargs):
return Chart.objects.filter(city__id=city_id)
return Chart.objects.all()

# TODO: This should be POST once rest authentication is implemented
@action(detail=True, methods=['get'])
@action(detail=True, methods=['post'])
def clear(self, request, **kwargs):
chart = self.get_object()
if not chart.clearable:
Expand All @@ -169,3 +176,51 @@ def clear(self, request, **kwargs):
chart.chart_data = {}
chart.save()
return HttpResponse(status=200)


class SimulationViewSet(GenericViewSet):
# Not based on a database model;
# Available Simulations must be hard-coded
# and associated with a function

@action(
detail=False,
methods=['get'],
url_path=r'available/city/(?P<city_id>[\d*]+)',
)
def list_available(self, request, city_id: int, **kwargs):
sims = get_available_simulations(city_id)
return HttpResponse(
json.dumps(sims),
status=200,
)

@action(
detail=False,
methods=['get'],
url_path=r'(?P<simulation_id>[\d*]+)/city/(?P<city_id>[\d*]+)/results',
)
def list_results(self, request, simulation_id: int, city_id: int, **kwargs):
return HttpResponse(
json.dumps(
list(
SimulationResultSerializer(s).data
for s in SimulationResult.objects.filter(
simulation_id=int(simulation_id), city__id=city_id
).all()
)
),
status=200,
)

@action(
detail=False,
methods=['post'],
url_path=r'run/(?P<simulation_id>[\d*]+)/city/(?P<city_id>[\d*]+)',
)
def run(self, request, simulation_id: int, city_id: int, **kwargs):
result = run_simulation(int(simulation_id), int(city_id), **request.data)
return HttpResponse(
json.dumps({'result': result}),
status=200,
)
Loading

0 comments on commit d7ecc06

Please sign in to comment.