Skip to content
11 changes: 10 additions & 1 deletion openfisca_core/holders.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from openfisca_core.data_storage import InMemoryStorage, OnDiskStorage
from openfisca_core.errors import PeriodMismatchError
from openfisca_core.indexed_enums import Enum
from openfisca_core.periods import MONTH, YEAR, ETERNITY
from openfisca_core.periods import WEEK, MONTH, YEAR, ETERNITY
from openfisca_core.tools import eval_expression

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -157,6 +157,7 @@ def set_input(self, period, array):
"""

period = periods.period(period)

if period.unit == ETERNITY and self.variable.definition_period != ETERNITY:
error_message = os.linesep.join([
'Unable to set a value for variable {0} for ETERNITY.',
Expand All @@ -171,16 +172,20 @@ def set_input(self, period, array):
self.variable.definition_period,
error_message
)

if self.variable.is_neutralized:
warning_message = "You cannot set a value for the variable {}, as it has been neutralized. The value you provided ({}) will be ignored.".format(self.variable.name, array)
return warnings.warn(
warning_message,
Warning
)

if self.variable.value_type in (float, int) and isinstance(array, str):
array = eval_expression(array)

if self.variable.set_input:
return self.variable.set_input(self, period, array)

return self._set(period, array)

def _to_array(self, value):
Expand Down Expand Up @@ -209,6 +214,7 @@ def _set(self, period, value):
if self.variable.definition_period != ETERNITY:
if period is None:
raise ValueError('A period must be specified to set values, except for variables with ETERNITY as as period_definition.')

if (self.variable.definition_period != period.unit or period.size > 1):
name = self.variable.name
period_size_adj = f'{period.unit}' if (period.size == 1) else f'{period.size}-{period.unit}s'
Expand Down Expand Up @@ -300,13 +306,16 @@ def set_input_divide_by_period(holder, period, array):
"""
if not isinstance(array, np.ndarray):
array = np.array(array)

period_size = period.size
period_unit = period.unit

if holder.variable.definition_period == MONTH:
cached_period_unit = periods.MONTH

elif holder.variable.definition_period == YEAR:
cached_period_unit = periods.YEAR

else:
raise ValueError('set_input_divide_by_period can be used only for yearly or monthly variables.')

Expand Down
99 changes: 89 additions & 10 deletions openfisca_core/periods.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
from typing import Dict


DAY = 'day'
MONTH = 'month'
YEAR = 'year'
ETERNITY = 'eternity'
YEAR = 'year'
MONTH = 'month'
DAY = 'day'
WEEK = 'week'
WEEKDAY = 'weekday'

INSTANT_PATTERN = re.compile(r'^\d{4}(?:-\d{1,2}){0,2}$') # matches '2015', '2015-01', '2015-01-01'

Expand Down Expand Up @@ -201,7 +203,7 @@ def offset(self, offset, unit):
Instant((2014, 12, 31))
"""
year, month, day = self
assert unit in (DAY, MONTH, YEAR), 'Invalid unit: {} of type {}'.format(unit, type(unit))
assert unit in (WEEKDAY, WEEK, DAY, MONTH, YEAR), 'Invalid unit: {} of type {}'.format(unit, type(unit))
if offset == 'first-of':
if unit == MONTH:
day = 1
Expand Down Expand Up @@ -291,28 +293,36 @@ def __str__(self):

>>> str(period(YEAR, '2014-2'))
'year:2014-02'

>>> str(period(MONTH, '2014-2'))
'2014-02'

>>> str(period(YEAR, 2012, size = 2))
'year:2012:2'

>>> str(period(MONTH, 2012, size = 2))
'month:2012-01:2'

>>> str(period(MONTH, 2012, size = 12))
'2012'

>>> str(period(YEAR, '2012-3', size = 2))
'year:2012-03:2'

>>> str(period(MONTH, '2012-3', size = 2))
'month:2012-03:2'

>>> str(period(MONTH, '2012-3', size = 12))
'year:2012-03'
"""

unit, start_instant, size = self

if unit == ETERNITY:
return 'ETERNITY'

year, month, day = start_instant
_, week, weekday = datetime.date(year, month, day).isocalendar()

# 1 year long period
if (unit == MONTH and size == 12 or unit == YEAR and size == 1):
Expand All @@ -322,9 +332,11 @@ def __str__(self):
else:
# rolling year
return '{}:{}-{:02d}'.format(YEAR, year, month)

# simple month
if unit == MONTH and size == 1:
return '{}-{:02d}'.format(year, month)

# several civil years
if unit == YEAR and month == 1:
return '{}:{}:{}'.format(unit, year, size)
Expand All @@ -335,6 +347,22 @@ def __str__(self):
else:
return '{}:{}-{:02d}-{:02d}:{}'.format(unit, year, month, day, size)

# 1 week
if (unit == WEEK and size == 1):
return f'{year}-W{week}'

# several weeks
if (unit == WEEK and size > 1):
return f'{unit}:{year}-W{week}:{size}'

# 1 weekday
if (unit == WEEKDAY and size == 1):
return f'{year}-W{week}-{weekday}'

# several weekdays
if (unit == WEEKDAY and size > 1):
return f'{unit}:{year}-W{week}-{weekday}:{size}'

# complex period
return '{}:{}-{:02d}:{}'.format(unit, year, month, size)

Expand Down Expand Up @@ -831,6 +859,40 @@ def parse_simple_period(value):
else:
return Period((YEAR, Instant((date.year, date.month, 1)), 1))

def parse_week_period(value):
"""
Parses ISO week date periods, such as 2010-W3 or 2012-W5-7.
"""

# If it's complex, next!
if len(value.split(':')) != 1:
return None

# If it's just a year, next!
if len(value.split('-')) == 1:
return None

# If there are no weeks, next!
if value.find("W") == -1:
return None

value = value.replace("W", "")
components = list(map(int, value.split("-")))

# If it has no weekdays return a week period
if len(components) == 2:
year, week = components
date = datetime.date.fromisocalendar(year, week, 1)
return Period((WEEK, Instant((date.year, date.month, date.day)), 1))

# If it has weekdays return a weekday period
if len(components) == 3:
year, week, day = components
date = datetime.date.fromisocalendar(year, week, day)
return Period((WEEKDAY, Instant((date.year, date.month, date.day)), 1))

return None

def raise_error(value):
message = linesep.join([
"Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '{}'.".format(value),
Expand All @@ -845,11 +907,19 @@ def raise_error(value):
# check the type
if isinstance(value, int):
return Period((YEAR, Instant((value, 1, 1)), 1))

if not isinstance(value, str):
raise_error(value)

# try to parse as a simple period
period = parse_simple_period(value)

if period is not None:
return period

# try to parse as a week period
period = parse_week_period(value)

if period is not None:
return period

Expand All @@ -861,23 +931,30 @@ def raise_error(value):

# left-most component must be a valid unit
unit = components[0]
if unit not in (DAY, MONTH, YEAR):
if unit not in (WEEKDAY, WEEK, DAY, MONTH, YEAR):
raise_error(value)

# middle component must be a valid iso period
base_period = parse_simple_period(components[1])

if not base_period:
raise_error(value)
# or a valid week/day period
base_period = parse_week_period(components[1])

if not base_period:
raise_error(value)

# period like year:2015-03 have a size of 1
if len(components) == 2:
size = 1

# if provided, make sure the size is an integer
elif len(components) == 3:
try:
size = int(components[2])
except ValueError:
raise_error(value)

# if there is more than 2 ":" in the string, the period is invalid
else:
raise_error(value)
Expand Down Expand Up @@ -912,10 +989,12 @@ def key_period_size(period):

def unit_weights():
return {
DAY: 100,
MONTH: 200,
YEAR: 300,
ETERNITY: 400,
WEEKDAY: 100,
WEEK: 200,
DAY: 300,
MONTH: 400,
YEAR: 500,
ETERNITY: 600,
}


Expand Down
5 changes: 3 additions & 2 deletions openfisca_core/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from openfisca_core import periods
from openfisca_core.entities import Entity
from openfisca_core.indexed_enums import Enum, EnumArray, ENUM_ARRAY_DTYPE
from openfisca_core.periods import DAY, MONTH, YEAR, ETERNITY
from openfisca_core.periods import ETERNITY, YEAR, MONTH, DAY, WEEK, WEEKDAY
from openfisca_core.tools import eval_expression


Expand Down Expand Up @@ -61,6 +61,7 @@
},
}

ALLOWED_DEFINITION_PERIODS = (ETERNITY, YEAR, MONTH, DAY, WEEK, WEEKDAY)

FORMULA_NAME_PREFIX = 'formula'

Expand Down Expand Up @@ -167,7 +168,7 @@ def __init__(self, baseline_variable = None):
else:
self.default_value = self.set(attr, 'default_value', allowed_type = self.value_type, default = VALUE_TYPES[self.value_type].get('default'))
self.entity = self.set(attr, 'entity', required = True, setter = self.set_entity)
self.definition_period = self.set(attr, 'definition_period', required = True, allowed_values = (DAY, MONTH, YEAR, ETERNITY))
self.definition_period = self.set(attr, 'definition_period', required = True, allowed_values = ALLOWED_DEFINITION_PERIODS)
self.label = self.set(attr, 'label', allowed_type = str, setter = self.set_label)
self.end = self.set(attr, 'end', allowed_type = str, setter = self.set_end)
self.reference = self.set(attr, 'reference', setter = self.set_reference)
Expand Down
38 changes: 38 additions & 0 deletions tests/core/test_holders.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from openfisca_core.memory_config import MemoryConfig
from openfisca_core.holders import Holder, set_input_dispatch_by_period
from openfisca_core.errors import PeriodMismatchError
from openfisca_core.entities import build_entity
from openfisca_core.variables import Variable
from openfisca_core.populations import Population
from .test_countries import tax_benefit_system

from pytest import fixture
Expand Down Expand Up @@ -212,3 +215,38 @@ def test_set_input_float_to_int(single):
simulation.person.get_holder('age').set_input(period, age)
result = simulation.calculate('age', period)
assert result == np.asarray([50])


# Unit tests - Set Input / Periods


ENTITY = build_entity(
key = "martian",
plural = "martians",
label = "People from Mars",
is_person = True,
)


@fixture
def variable():
class EternalVariable(Variable):
value_type = bool
entity = ENTITY
definition_period = ETERNITY

return EternalVariable()


@fixture
def population():
return Population(ENTITY)


@fixture
def holder(variable, population):
return Holder(variable, population)


def test_holder(holder):
assert holder
Loading