diff --git a/CHANGELOG.md b/CHANGELOG.md index 684c94323a..c13ab894e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 35.7.0 [#1070](https://github.com/openfisca/openfisca-core/pulls/1070) + +#### New Features + +- Add group population shortcut to containing groups entities + ## 35.6.0 [#1054](https://github.com/openfisca/openfisca-core/pull/1054) #### New Features diff --git a/openfisca_core/entities/group_entity.py b/openfisca_core/entities/group_entity.py index d0a4113b35..0d58acc6ba 100644 --- a/openfisca_core/entities/group_entity.py +++ b/openfisca_core/entities/group_entity.py @@ -2,11 +2,28 @@ class GroupEntity(Entity): - """ - Represents an entity composed of several persons with different roles, on which calculations are run. + """Represents an entity containing several others with different roles. + + A :class:`.GroupEntity` represents an :class:`.Entity` containing + several other :class:`.Entity` with different :class:`.Role`, and on + which calculations can be run. + + Args: + key: A key to identify the group entity. + plural: The ``key``, pluralised. + label: A summary description. + doc: A full description. + roles: The list of :class:`.Role` of the group entity. + containing_entities: The list of keys of group entities whose members + are guaranteed to be a superset of this group's entities. + + .. versionchanged:: 35.7.0 + Added ``containing_entities``, that allows the defining of group + entities which entirely contain other group entities. + """ - def __init__(self, key, plural, label, doc, roles): + def __init__(self, key, plural, label, doc, roles, containing_entities = ()): super().__init__(key, plural, label, doc) self.roles_description = roles self.roles = [] @@ -23,3 +40,4 @@ def __init__(self, key, plural, label, doc, roles): role.max = len(role.subroles) self.flattened_roles = sum([role2.subroles or [role2] for role2 in self.roles], []) self.is_person = False + self.containing_entities = containing_entities diff --git a/openfisca_core/entities/helpers.py b/openfisca_core/entities/helpers.py index d1c66a66ba..86d7bb6a6b 100644 --- a/openfisca_core/entities/helpers.py +++ b/openfisca_core/entities/helpers.py @@ -1,8 +1,8 @@ from openfisca_core import entities -def build_entity(key, plural, label, doc = "", roles = None, is_person = False, class_override = None): +def build_entity(key, plural, label, doc = "", roles = None, is_person = False, class_override = None, containing_entities = ()): if is_person: return entities.Entity(key, plural, label, doc) else: - return entities.GroupEntity(key, plural, label, doc, roles) + return entities.GroupEntity(key, plural, label, doc, roles, containing_entities = containing_entities) diff --git a/openfisca_core/projectors/helpers.py b/openfisca_core/projectors/helpers.py index 502eee1dfb..7bc55e0fd9 100644 --- a/openfisca_core/projectors/helpers.py +++ b/openfisca_core/projectors/helpers.py @@ -21,3 +21,5 @@ def get_projector_from_shortcut(population, shortcut, parent = None): role = next((role for role in population.entity.flattened_roles if (role.max == 1) and (role.key == shortcut)), None) if role: return projectors.UniqueRoleToEntityProjector(population, role, parent) + if shortcut in population.entity.containing_entities: + return getattr(projectors.FirstPersonToEntityProjector(population, parent), shortcut) diff --git a/setup.cfg b/setup.cfg index 108d152f59..25b348db77 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,12 +6,13 @@ ; E501: We do not enforce a maximum line length. ; F403/405: We ignore * imports. ; R0401: We avoid cyclic imports —required for unit/doc tests. +; RST301: We use Google Python Style (see https://pypi.org/project/flake8-rst-docstrings/) ; W503/504: We break lines before binary operators (Knuth's style). [flake8] extend-ignore = D hang-closing = true -ignore = E128,E251,F403,F405,E501,W503,W504 +ignore = E128,E251,F403,F405,E501,RST301,W503,W504 in-place = true include-in-doctest = openfisca_core/commons openfisca_core/types rst-directives = attribute, deprecated, seealso, versionadded, versionchanged diff --git a/setup.py b/setup.py index fb03815a1d..a9164b8cbd 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ setup( name = 'OpenFisca-Core', - version = '35.6.0', + version = '35.7.0', author = 'OpenFisca Team', author_email = 'contact@openfisca.org', classifiers = [ diff --git a/tests/core/test_formulas.py b/tests/core/test_formulas.py index 876ca239d1..8851671755 100644 --- a/tests/core/test_formulas.py +++ b/tests/core/test_formulas.py @@ -94,3 +94,89 @@ def test_compare_multiplication_and_switch(simulation, month): uses_multiplication = simulation.calculate('uses_multiplication', period = month) uses_switch = simulation.calculate('uses_switch', period = month) assert numpy.all(uses_switch == uses_multiplication) + + +def test_group_encapsulation(): + """Projects a calculation to all members of an entity. + + When a household contains more than one family + Variables can be defined for the the household + And calculations are projected to all the member families. + + """ + from openfisca_core.taxbenefitsystems import TaxBenefitSystem + from openfisca_core.entities import build_entity + from openfisca_core.periods import ETERNITY + + person_entity = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + family_entity = build_entity( + key="family", + plural="families", + label="A family (all members in the same household)", + containing_entities=["household"], + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + household_entity = build_entity( + key="household", + plural="households", + label="A household, containing one or more families", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person_entity, family_entity, household_entity] + + system = TaxBenefitSystem(entities) + + class household_level_variable(Variable): + value_type = int + entity = household_entity + definition_period = ETERNITY + + class projected_family_level_variable(Variable): + value_type = int + entity = family_entity + definition_period = ETERNITY + + def formula(family, period): + return family.household("household_level_variable", period) + + system.add_variables(household_level_variable, projected_family_level_variable) + + simulation = SimulationBuilder().build_from_dict(system, { + "people": { + "person1": {}, + "person2": {}, + "person3": {} + }, + "families": { + "family1": { + "members": ["person1", "person2"] + }, + "family2": { + "members": ["person3"] + }, + }, + "households": { + "household1": { + "members": ["person1", "person2", "person3"], + "household_level_variable": { + "eternity": 5 + } + } + } + }) + + assert (simulation.calculate("projected_family_level_variable", "2021-01-01") == 5).all() diff --git a/tests/core/test_projectors.py b/tests/core/test_projectors.py new file mode 100644 index 0000000000..be401bbec8 --- /dev/null +++ b/tests/core/test_projectors.py @@ -0,0 +1,316 @@ +from openfisca_core.simulations.simulation_builder import SimulationBuilder +from openfisca_core.taxbenefitsystems import TaxBenefitSystem +from openfisca_core.entities import build_entity +from openfisca_core.model_api import Enum, Variable, ETERNITY +import numpy as np + + +def test_shortcut_to_containing_entity_provided(): + """ + Tests that, when an entity provides a containing entity, + the shortcut to that containing entity is provided. + """ + person_entity = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + family_entity = build_entity( + key="family", + plural="families", + label="A family (all members in the same household)", + containing_entities=["household"], + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + household_entity = build_entity( + key="household", + plural="households", + label="A household, containing one or more families", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person_entity, family_entity, household_entity] + + system = TaxBenefitSystem(entities) + simulation = SimulationBuilder().build_from_dict(system, {}) + assert simulation.populations["family"].household.entity.key == "household" + + +def test_shortcut_to_containing_entity_not_provided(): + """ + Tests that, when an entity doesn't provide a containing + entity, the shortcut to that containing entity is not provided. + """ + person_entity = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + family_entity = build_entity( + key="family", + plural="families", + label="A family (all members in the same household)", + containing_entities=[], + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + household_entity = build_entity( + key="household", + plural="households", + label="A household, containing one or more families", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person_entity, family_entity, household_entity] + + system = TaxBenefitSystem(entities) + simulation = SimulationBuilder().build_from_dict(system, {}) + try: + simulation.populations["family"].household + raise AssertionError() + except AttributeError: + pass + + +def test_enum_projects_downwards(): + """ + Test that an Enum-type household-level variable projects + values onto its members correctly. + """ + + person = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + household = build_entity( + key="household", + plural="households", + label="A household", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person, household] + + system = TaxBenefitSystem(entities) + + class enum(Enum): + FIRST_OPTION = "First option" + SECOND_OPTION = "Second option" + + class household_enum_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = household + definition_period = ETERNITY + + class projected_enum_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = person + definition_period = ETERNITY + + def formula(person, period): + return person.household("household_enum_variable", period) + + system.add_variables(household_enum_variable, projected_enum_variable) + + simulation = SimulationBuilder().build_from_dict(system, { + "people": { + "person1": {}, + "person2": {}, + "person3": {} + }, + "households": { + "household1": { + "members": ["person1", "person2", "person3"], + "household_enum_variable": { + "eternity": "SECOND_OPTION" + } + } + } + }) + + assert (simulation.calculate("projected_enum_variable", "2021-01-01").decode_to_str() == np.array(["SECOND_OPTION"] * 3)).all() + + +def test_enum_projects_upwards(): + """ + Test that an Enum-type person-level variable projects + values onto its household (from the first person) correctly. + """ + + person = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + household = build_entity( + key="household", + plural="households", + label="A household", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person, household] + + system = TaxBenefitSystem(entities) + + class enum(Enum): + FIRST_OPTION = "First option" + SECOND_OPTION = "Second option" + + class household_projected_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = household + definition_period = ETERNITY + + def formula(household, period): + return household.value_from_first_person(household.members("person_enum_variable", period)) + + class person_enum_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = person + definition_period = ETERNITY + + system.add_variables(household_projected_variable, person_enum_variable) + + simulation = SimulationBuilder().build_from_dict(system, { + "people": { + "person1": { + "person_enum_variable": { + "ETERNITY": "SECOND_OPTION" + } + }, + "person2": {}, + "person3": {} + }, + "households": { + "household1": { + "members": ["person1", "person2", "person3"], + } + } + }) + + assert (simulation.calculate("household_projected_variable", "2021-01-01").decode_to_str() == np.array(["SECOND_OPTION"])).all() + + +def test_enum_projects_between_containing_groups(): + """ + Test that an Enum-type person-level variable projects + values onto its household (from the first person) correctly. + """ + + person_entity = build_entity( + key="person", + plural="people", + label="A person", + is_person=True, + ) + family_entity = build_entity( + key="family", + plural="families", + label="A family (all members in the same household)", + containing_entities=["household"], + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + household_entity = build_entity( + key="household", + plural="households", + label="A household, containing one or more families", + roles=[{ + "key": "member", + "plural": "members", + "label": "Member", + }] + ) + + entities = [person_entity, family_entity, household_entity] + + system = TaxBenefitSystem(entities) + + class enum(Enum): + FIRST_OPTION = "First option" + SECOND_OPTION = "Second option" + + class household_level_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = household_entity + definition_period = ETERNITY + + class projected_family_level_variable(Variable): + value_type = Enum + possible_values = enum + default_value = enum.FIRST_OPTION + entity = family_entity + definition_period = ETERNITY + + def formula(family, period): + return family.household("household_level_variable", period) + + system.add_variables(household_level_variable, projected_family_level_variable) + + simulation = SimulationBuilder().build_from_dict(system, { + "people": { + "person1": {}, + "person2": {}, + "person3": {} + }, + "families": { + "family1": { + "members": ["person1", "person2"] + }, + "family2": { + "members": ["person3"] + }, + }, + "households": { + "household1": { + "members": ["person1", "person2", "person3"], + "household_level_variable": { + "eternity": "SECOND_OPTION" + } + } + } + }) + + assert (simulation.calculate("projected_family_level_variable", "2021-01-01").decode_to_str() == np.array(["SECOND_OPTION"])).all()