Skip to content
This repository has been archived by the owner on Aug 28, 2023. It is now read-only.

Commit

Permalink
Add IndicatorParams framework
Browse files Browse the repository at this point in the history
Breaks out the definition, documentation and validation of Indicator
parameters to a separate class. Like other parts of Indicator, the
params_class is provided to each indicator and used to validate them.
Any class which needs a different set of IndicatorParams is free to
override with its own definition.

See params.py for more documentation about IndicatorParams specifics
  • Loading branch information
CloudNiner committed Dec 9, 2016
1 parent bf699ae commit cd28b65
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 59 deletions.
30 changes: 8 additions & 22 deletions django/climate_change_api/climate_data/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.http import HttpResponseRedirect
from django.utils.http import urlencode

from rest_framework import filters, status, viewsets
from rest_framework import filters, serializers, status, viewsets
from rest_framework.decorators import api_view, detail_route, list_route
from rest_framework.exceptions import NotFound, ParseError
from rest_framework.response import Response
Expand All @@ -20,7 +20,7 @@
ClimateModelSerializer,
ClimateCityScenarioDataSerializer,
ScenarioSerializer)
from indicators import indicator_factory, list_available_indicators
from indicators import indicator_factory, list_available_indicators, params

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -286,36 +286,22 @@ def climate_indicator(request, *args, **kwargs):
else:
model_list = ClimateModel.objects.all()

agg_param = request.query_params.get('agg', None)
aggregations = agg_param.split(',') if agg_param else None
years_param = request.query_params.get('years', None)
units_param = request.query_params.get('units', None)

indicator_key = kwargs['indicator']
IndicatorClass = indicator_factory(indicator_key)
if not IndicatorClass:
raise ParseError(detail='Must provide a valid indicator')
data = IndicatorClass(city,
scenario,
models=models_param,
years=years_param,
serializer_aggregations=aggregations,
parameters=request.query_params,
units=units_param).calculate()

if units_param and units_param not in IndicatorClass.available_units:
raise NotFound(detail='Cannot convert indicator {} to units {}.'.format(indicator_key,
units_param))

if not units_param:
units_param = IndicatorClass.default_units
try:
indicator_class = IndicatorClass(city, scenario, parameters=request.query_params)
except params.ValidationError as e:
raise serializers.ValidationError(str(e))
data = indicator_class.calculate()

return Response(OrderedDict([
('city', CitySerializer(city).data),
('scenario', scenario.name),
('indicator', IndicatorClass.to_dict()),
('climate_models', [m.name for m in model_list]),
('units', units_param),
('units', indicator_class.params.units),
('data', data),
]))

Expand Down
28 changes: 10 additions & 18 deletions django/climate_change_api/indicators/abstract_indicators.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from climate_data.models import ClimateData, ClimateDataSource
from climate_data.filters import ClimateDataFilterSet
from .params import IndicatorParams
from .serializers import IndicatorSerializer
from .unit_converters import DaysUnitsMixin

Expand All @@ -22,6 +23,7 @@ class Indicator(object):
variables = ClimateData.VARIABLE_CHOICES
filters = None
serializer_class = IndicatorSerializer
params_class = IndicatorParams

# Subclasses should use a units mixin from 'unit_converters' to define these units
# attributes and any necessary conversion functions
Expand All @@ -30,27 +32,17 @@ class Indicator(object):
available_units = (None,)
parameters = None

def __init__(self, city, scenario, models=None, years=None,
serializer_aggregations=None, units=None, parameters=None):
def __init__(self, city, scenario, parameters=None):
if not city:
raise ValueError('Indicator constructor requires a city instance')
if not scenario:
raise ValueError('Indicator constructor requires a scenario instance')

self.city = city
self.scenario = scenario
self.models = models
self.years = years
self.serializer_aggregations = serializer_aggregations

# Set and validate desired units
self.units = units if units is not None else self.default_units
if self.units not in self.available_units:
raise ValueError('Cannot convert to requested units ({})'.format(self.units))

if self.parameters is not None and parameters is not None:
self.parameters = {key: parameters.get(key, default)
for (key, default) in self.parameters.items()}
self.params = self.params_class(parameters, self.available_units, self.default_units)
self.params.validate()

self.queryset = self.get_queryset()
self.queryset = self.filter_objects()
Expand Down Expand Up @@ -88,8 +80,8 @@ def get_queryset(self):
filter_set = ClimateDataFilterSet()
queryset = (ClimateData.objects.filter(map_cell=self.city.map_cell)
.filter(data_source__scenario=self.scenario))
queryset = filter_set.filter_years(queryset, self.years)
queryset = filter_set.filter_models(queryset, self.models)
queryset = filter_set.filter_years(queryset, self.params.years)
queryset = filter_set.filter_models(queryset, self.params.models)
return queryset

def filter_objects(self):
Expand All @@ -114,9 +106,9 @@ def convert_units(self, aggregations):
@param aggregations list-of-dicts returned by aggregate method
@returns Dict in same format as the aggregations parameter, with values converted
to `self.units`
to `self.params.units`
"""
converter = self.getConverter(self.storage_units, self.units)
converter = self.getConverter(self.storage_units, self.params.units)
for item in aggregations:
if item['value'] is not None:
item['value'] = converter(item['value'])
Expand Down Expand Up @@ -151,7 +143,7 @@ def calculate(self):
aggregations = self.convert_units(aggregations)
collations = self.collate_results(aggregations)
return self.serializer.to_representation(collations,
aggregations=self.serializer_aggregations)
aggregations=self.params.agg)


class YearlyIndicator(Indicator):
Expand Down
25 changes: 13 additions & 12 deletions django/climate_change_api/indicators/indicators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
YearlyMaxConsecutiveDaysIndicator, YearlySequenceIndicator,
MonthlyAggregationIndicator, MonthlyCountIndicator,
DailyRawIndicator)
from .params import PercentileIndicatorParams
from .unit_converters import (TemperatureUnitsMixin, PrecipUnitsMixin,
DaysUnitsMixin, CountUnitsMixin)

Expand Down Expand Up @@ -93,39 +94,39 @@ class YearlyExtremePrecipitationEvents(CountUnitsMixin, YearlyCountIndicator):
label = 'Yearly Extreme Precipitation Events'
description = ('Total number of times per year daily precipitation exceeds the specified '
'(Default 99th) percentile of observations from 1960 to 1995')
params_class = PercentileIndicatorParams
variables = ('pr',)
parameters = {'percentile': 99}

@property
def conditions(self):
return {'pr__gt': F('map_cell__baseline__pr'),
'map_cell__baseline__percentile': self.parameters['percentile']}
'map_cell__baseline__percentile': self.params.percentile}


class YearlyExtremeHeatEvents(CountUnitsMixin, YearlyCountIndicator):
label = 'Yearly Extreme Heat Events'
description = ('Total number of times per year daily maximum temperature exceeds the specified '
'(Default 99th) percentile of observations from 1960 to 1995')
params_class = PercentileIndicatorParams
variables = ('tasmax',)
parameters = {'percentile': 99}

@property
def conditions(self):
return {'tasmax__gt': F('map_cell__baseline__tasmax'),
'map_cell__baseline__percentile': self.parameters['percentile']}
'map_cell__baseline__percentile': self.params.percentile}


class YearlyExtremeColdEvents(CountUnitsMixin, YearlyCountIndicator):
label = 'Yearly Extreme Cold Events'
description = ('Total number of times per year daily minimum temperature is below the specified '
'(Default 1st) percentile of observations from 1960 to 1995')
params_class = PercentileIndicatorParams
variables = ('tasmin',)
parameters = {'percentile': 1}

@property
def conditions(self):
return {'tasmin__lt': F('map_cell__baseline__tasmin'),
'map_cell__baseline__percentile': self.parameters['percentile']}
'map_cell__baseline__percentile': self.params.percentile}


class HeatWaveDurationIndex(YearlyMaxConsecutiveDaysIndicator):
Expand Down Expand Up @@ -195,39 +196,39 @@ class MonthlyExtremePrecipitationEvents(CountUnitsMixin, MonthlyCountIndicator):
label = 'Monthly Extreme Precipitation Events'
description = ('Total number of times per month daily precipitation exceeds the specified '
'(Default 99th) percentile of observations from 1960 to 1995')
params_class = PercentileIndicatorParams
variables = ('pr',)
parameters = {'percentile': 99}

@property
def conditions(self):
return {'pr__gt': F('map_cell__baseline__pr'),
'map_cell__baseline__percentile': self.parameters['percentile']}
'map_cell__baseline__percentile': self.params.percentile}


class MonthlyExtremeHeatEvents(CountUnitsMixin, MonthlyCountIndicator):
label = 'Monthly Extreme Heat Events'
description = ('Total number of times per month daily maximum temperature exceeds the specified '
'(Default 99th) percentile of observations from 1960 to 1995')
params_class = PercentileIndicatorParams
variables = ('tasmax',)
parameters = {'percentile': 99}

@property
def conditions(self):
return {'tasmax__gt': F('map_cell__baseline__tasmax'),
'map_cell__baseline__percentile': self.parameters['percentile']}
'map_cell__baseline__percentile': self.params.percentile}


class MonthlyExtremeColdEvents(CountUnitsMixin, MonthlyCountIndicator):
label = 'Monthly Extreme Cold Events'
description = ('Total number of times per month daily minimum temperature is below the specified '
'(Default 1st) percentile of observations from 1960 to 1995')
params_class = PercentileIndicatorParams
variables = ('tasmin',)
parameters = {'percentile': 1}

@property
def conditions(self):
return {'tasmin__lt': F('map_cell__baseline__tasmin'),
'map_cell__baseline__percentile': self.parameters['percentile']}
'map_cell__baseline__percentile': self.params.percentile}



Expand Down
95 changes: 95 additions & 0 deletions django/climate_change_api/indicators/params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@

class ValidationError(Exception):
pass


class IndicatorParams(object):
""" Superclass used to define parameters necessary for an Indicator class to function
This class defines the defaults for all indicators, which are all optional.
New IndicatorParams subclasses should implement a new validate_paramname(self, value)
method for each parameter they add. This method should:
- Return a default value if the parameter is optional
- Raise indicators.params.ValidationError(message) if the value is invalid
- Ensure the paramname is added to the self.PARAMS iterable
After calling validate(), the validated parameter values are available as instance attributes.
"""
PARAMS = ('models', 'years', 'agg', 'units',)

def __init__(self, query_params, available_units, default_units):
"""
@param query_params (dict[str:str]): Dict of key/value pairs to parse
@param available_units (Iterable[str]): List of unit strings this class should check the
'units' query_params against
@param default_units (string): Default value for 'units' to set if none provided
in query_params
"""
self._params = query_params
self._available_units = available_units
self._default_units = default_units
self.validated = False

def validate(self):
""" Validate all parameters found in self.PARAMS """
for param in self.PARAMS:
value = self._params.get(param, None)
validation_fn = getattr(self, 'validate_' + param)
clean_value = validation_fn(value)
setattr(self, param, clean_value)
self.validated = True

def validate_models(self, value):
return self._check_is_string('models', value) if value is not None else None

def validate_years(self, value):
return self._check_is_string('years', value) if value is not None else None

def validate_agg(self, value):
return self._check_is_string('agg', value) if value is not None else None

def validate_units(self, value):
if value is None:
value = self._default_units
if value not in self._available_units:
raise ValidationError('Unit {} must be one of {}'.format(value, self._available_units))
return value

def __repr__(self):
""" Override repr to improve class self-documentation """
representation = {
'validated': self.validated,
'PARAMS': self.PARAMS,
}
if self.validated:
for p in self.PARAMS:
representation[p] = getattr(self, p)
else:
for k, v in self._params.iteritems():
representation[k] = v
return str(representation)

def _check_is_string(self, param, value):
""" Private helper method to raise ValidationError if value is not a string """
if not isinstance(value, basestring):
raise ValidationError('{} must be a string comma separated list'.format(param))
return value


class PercentileIndicatorParams(IndicatorParams):
""" Extend IndicatorParams with a non-optional 'percentile' parameter """

PARAMS = IndicatorParams.PARAMS + ('percentile',)

def validate_percentile(self, value):
if value is None:
raise ValidationError('percentile param required')
try:
int_value = int(value)
except ValueError:
raise ValidationError('percentile param must be an integer')
if int_value < 1 or int_value > 99:
raise ValidationError('percentile param must be within the range (0-100)')
return int_value
Loading

0 comments on commit cd28b65

Please sign in to comment.