Skip to content

Commit afe8b50

Browse files
authored
Merge pull request #283 from American-Institutes-for-Research/HEA-1072/denormalize_annual_kcals_cost
Fix slow annual_kcals_cost - see HEA-1072
2 parents ce55aa3 + 80fdb64 commit afe8b50

9 files changed

Lines changed: 357 additions & 42 deletions

File tree

.github/workflows/01-build-then-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ env:
3030
jobs:
3131
lint:
3232
runs-on: [ fewsnet ]
33-
container: "python:3.11"
33+
container: "python:3.12"
3434

3535
steps:
3636
- uses: "actions/checkout@v4"

apps/baseline/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ class BaselineConfig(AppConfig):
66
default_auto_field = "django.db.models.BigAutoField"
77
name = "baseline"
88
verbose_name = _("baseline")
9+
10+
def ready(self):
11+
import baseline.signals # noqa: F401
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 5.2.11 on 2026-05-03 14:30
2+
3+
from django.db import migrations, models
4+
5+
6+
def populate_annual_kcals_cost(apps, schema_editor):
7+
schema_editor.execute("""
8+
UPDATE baseline_livelihoodzonebaseline
9+
SET _annual_kcals_cost = get_annual_kcals_cost(id, 'P')
10+
""")
11+
12+
13+
def clear_annual_kcals_cost(apps, schema_editor):
14+
schema_editor.execute("""
15+
UPDATE baseline_livelihoodzonebaseline
16+
SET _annual_kcals_cost = NULL
17+
""")
18+
19+
20+
class Migration(migrations.Migration):
21+
22+
dependencies = [
23+
("baseline", "0033_add_annual_kcals_cost_function"),
24+
]
25+
26+
operations = [
27+
migrations.AddField(
28+
model_name="livelihoodzonebaseline",
29+
name="_annual_kcals_cost",
30+
field=models.FloatField(
31+
blank=True,
32+
help_text="Annual cost per person of 100% of recommended kcals in the baseline currency.",
33+
null=True,
34+
verbose_name="Annual kcals cost",
35+
),
36+
),
37+
migrations.RunPython(populate_annual_kcals_cost, clear_annual_kcals_cost),
38+
]

apps/baseline/models.py

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -348,22 +348,23 @@ class Language(models.TextChoices):
348348
verbose_name=_("Currency"),
349349
help_text=_("Default currency for income or expenditure from Livelihood Activities within this Baseline."),
350350
)
351+
# Derived field - always set by calculate_fields()
352+
_annual_kcals_cost = models.FloatField(
353+
blank=True,
354+
null=True,
355+
verbose_name=_("Annual kcals cost"),
356+
help_text=_("Annual cost per person of 100% of recommended kcals in the baseline currency."),
357+
)
351358
objects = LivelihoodZoneBaselineManager()
352359

353-
def _get_annual_kcals_cost(self):
360+
def _get_annual_kcals_cost_sql(self):
354361
"""
355362
Calculate the annual cost per person of 100% of recommended kcals.
356363
357-
This is required to calculate the combined food and income as kcals.
358-
359-
It is defined as the cost of providing 100% of required kcals for the P
360-
Wealth Group divided by the average_household_size. The cost is
361-
calculated as the cost of Basket 2 (Other food survival) plus the cost
362-
of providing the remainder of kcals by purchasing the main staple. All
363-
costs are in the currency for the BSS.
364-
365-
This calculation is based on the formulae in the Graphs worksheet.
364+
This method uses a stored SQL function and is used for troubleshooting.
366365
"""
366+
if not self.pk:
367+
return None
367368
return (
368369
type(self)
369370
.objects.filter(pk=self.pk)
@@ -377,10 +378,22 @@ def _get_annual_kcals_cost(self):
377378
.get()
378379
)
379380

380-
def _get_annual_kcals_cost_python(self):
381+
def _get_annual_kcals_cost(self):
381382
"""
382-
A Python implementation of the annual_kcals_cost calculation, used for troubleshooting.
383+
Calculate the annual cost per person of 100% of recommended kcals.
384+
385+
This is required to calculate the combined food and income as kcals.
386+
387+
It is defined as the cost of providing 100% of required kcals for the P
388+
Wealth Group divided by the average_household_size. The cost is
389+
calculated as the cost of Basket 2 (Other food survival) plus the cost
390+
of providing the remainder of kcals by purchasing the main staple. All
391+
costs are in the currency for the BSS.
392+
393+
This calculation is based on the formulae in the Graphs worksheet.
383394
"""
395+
if not self.pk:
396+
return None
384397
poor_main_staple = LivelihoodProductCategory.objects.filter(
385398
basket=LivelihoodProductCategory.ProductBasket.MAIN_STAPLE,
386399
baseline_livelihood_activity__wealth_group__livelihood_zone_baseline=self,
@@ -400,6 +413,10 @@ def _get_annual_kcals_cost_python(self):
400413
)
401414
)
402415
poor_main_staple = poor_main_staple[0]
416+
poor_household_size = poor_main_staple.baseline_livelihood_activity.wealth_group.average_household_size
417+
if not poor_household_size:
418+
# Cannot calculate without household size
419+
return None
403420
poor_other_food = LivelihoodProductCategory.objects.filter(
404421
basket=LivelihoodProductCategory.ProductBasket.SURVIVAL_OTHER_FOOD,
405422
baseline_livelihood_activity__wealth_group__livelihood_zone_baseline=self,
@@ -412,12 +429,11 @@ def _get_annual_kcals_cost_python(self):
412429
F("baseline_livelihood_activity__expenditure") * F("percentage_allocation_to_basket")
413430
),
414431
)
415-
poor_household_size = poor_main_staple.baseline_livelihood_activity.wealth_group.average_household_size
416432
main_staple_kcals_per_unit = poor_main_staple.baseline_livelihood_activity.extra.get(
417433
"product__kcals_per_unit",
418434
poor_main_staple.baseline_livelihood_activity.livelihood_strategy.product.kcals_per_unit,
419435
)
420-
main_staple_percentage_kcals_required = 1 - poor_other_food["total_percentage_kcals"]
436+
main_staple_percentage_kcals_required = 1 - (poor_other_food["total_percentage_kcals"] or 0)
421437
main_staple_cost = (
422438
2100 # kcals per person per day
423439
* 365 # days per year
@@ -426,17 +442,29 @@ def _get_annual_kcals_cost_python(self):
426442
/ main_staple_kcals_per_unit
427443
* poor_main_staple.baseline_livelihood_activity.price
428444
)
429-
total_cost = main_staple_cost + poor_other_food["total_expenditure"]
445+
total_cost = main_staple_cost + (poor_other_food["total_expenditure"] or 0)
430446
total_food_cost_per_person = total_cost / poor_household_size
431447
return total_food_cost_per_person
432448

433-
def get_annual_kcals_cost(self):
434-
key = f"livelihood_zone_baseline~{self.pk}~annual_kcals_cost"
435-
annual_kcals_cost = cache.get(key)
436-
if not annual_kcals_cost:
437-
annual_kcals_cost = self._get_annual_kcals_cost()
438-
cache.set(key, annual_kcals_cost, 60 * 60 * 24)
439-
return annual_kcals_cost
449+
@property
450+
def annual_kcals_cost(self):
451+
return self._annual_kcals_cost
452+
453+
def calculate_fields(self):
454+
self._annual_kcals_cost = self._get_annual_kcals_cost()
455+
456+
def save(self, *args, **kwargs):
457+
self.calculate_fields()
458+
self.full_clean(
459+
exclude=[field.name for field in self._meta.fields if isinstance(field, models.ForeignKey)],
460+
validate_unique=False,
461+
)
462+
463+
# Make sure that _annual_kcals_cost is included in the update_fields if the update_fields argument is provided.
464+
update_fields = kwargs.get("update_fields")
465+
if update_fields is not None:
466+
kwargs["update_fields"] = set(update_fields) | {"_annual_kcals_cost"}
467+
super().save(*args, **kwargs)
440468

441469
def natural_key(self):
442470
try:
@@ -734,7 +762,7 @@ class WealthGroup(common_models.Model):
734762

735763
@cached_property
736764
def household_annual_kcals_cost(self):
737-
annual_kcals_cost = self.livelihood_zone_baseline.get_annual_kcals_cost()
765+
annual_kcals_cost = self.livelihood_zone_baseline.annual_kcals_cost
738766
if annual_kcals_cost and self.average_household_size:
739767
return annual_kcals_cost * self.average_household_size
740768

@@ -759,7 +787,7 @@ def _get_survival_threshold_as_percentage_kcals(self):
759787
This basket is defined for the Poor Wealth Group, and other values use
760788
the same expenditure, scaled according to average household size.
761789
"""
762-
annual_kcals_cost = self.livelihood_zone_baseline.get_annual_kcals_cost()
790+
annual_kcals_cost = self.livelihood_zone_baseline.annual_kcals_cost
763791
poor_survival_non_food = (
764792
LivelihoodProductCategory.objects.filter(
765793
basket=LivelihoodProductCategory.ProductBasket.SURVIVAL_NON_FOOD,
@@ -804,7 +832,7 @@ def survival_threshold_as_percentage_kcals(self):
804832

805833
@cached_property
806834
def survival_threshold_as_cash(self):
807-
annual_kcals_cost = self.livelihood_zone_baseline.get_annual_kcals_cost()
835+
annual_kcals_cost = self.livelihood_zone_baseline.annual_kcals_cost
808836
if annual_kcals_cost and self.survival_threshold_as_percentage_kcals and self.average_household_size:
809837
return self.survival_threshold_as_percentage_kcals * self.average_household_size * annual_kcals_cost
810838

@@ -847,7 +875,7 @@ def _get_livelihoods_protection_threshold_as_percentage_kcals(self):
847875
Livelihoods Protection Basket then the amount is inherited from the Poor wealth group,
848876
rather than based on typical consumption for the Wealth Group in the Reference Year.
849877
"""
850-
annual_kcals_cost = self.livelihood_zone_baseline.get_annual_kcals_cost()
878+
annual_kcals_cost = self.livelihood_zone_baseline.annual_kcals_cost
851879

852880
livelihood_protection_qs = LivelihoodProductCategory.objects.filter(
853881
basket=LivelihoodProductCategory.ProductBasket.LIVELIHOODS_PROTECTION,
@@ -981,7 +1009,7 @@ def livelihoods_protection_threshold_as_percentage_kcals(self):
9811009

9821010
@cached_property
9831011
def livelihoods_protection_threshold_as_cash(self):
984-
annual_kcals_cost = self.livelihood_zone_baseline.get_annual_kcals_cost()
1012+
annual_kcals_cost = self.livelihood_zone_baseline.annual_kcals_cost
9851013
if (
9861014
annual_kcals_cost
9871015
and self.livelihoods_protection_threshold_as_percentage_kcals
@@ -1754,7 +1782,7 @@ def total_income_as_percentage_kcals(self):
17541782
"""
17551783
The total food consumed and income received as percentage of required household kcals.
17561784
"""
1757-
annual_kcals_cost = self.livelihood_zone_baseline.get_annual_kcals_cost()
1785+
annual_kcals_cost = self.livelihood_zone_baseline.annual_kcals_cost
17581786
percentage_kcals = self.percentage_kcals or 0
17591787
income = self.income or 0
17601788
if self.wealth_group.average_household_size:
@@ -1769,7 +1797,7 @@ def total_income_as_cash(self):
17691797
"""
17701798
The total food consumed and income received as an amount in the BSS currency.
17711799
"""
1772-
annual_kcals_cost = self.livelihood_zone_baseline.get_annual_kcals_cost()
1800+
annual_kcals_cost = self.livelihood_zone_baseline.annual_kcals_cost
17731801
percentage_kcals = self.percentage_kcals or 0
17741802
income = self.income or 0
17751803
if self.wealth_group.average_household_size:
@@ -1854,15 +1882,17 @@ def validate_expenditure(self):
18541882
18551883
However, some LivelihoodActivity subclasses, such as FoodPurchase and
18561884
OtherPurchase, involve spending money to acquire the item, in which
1857-
case we must validate that expenditure = quantity_produced * price
1885+
case we must validate that expenditure = quantity_purchased * price
18581886
"""
1859-
quantity_produced = self.quantity_produced or 0
1887+
quantity_produced_or_purchased = self.quantity_produced or self.quantity_purchased or 0
18601888
price = self.price or 0
18611889
expenditure = self.expenditure or 0
18621890

1863-
if self.expenditure and not math.isclose(expenditure, quantity_produced * price):
1891+
if self.expenditure and not math.isclose(expenditure, quantity_produced_or_purchased * price):
18641892
raise ValidationError(
1865-
_("Expenditure for a Livelihood Activity must be quantity produced multiplied by price")
1893+
_(
1894+
"Expenditure for a Livelihood Activity must be quantity produced or quantity purchased multiplied by price"
1895+
)
18661896
)
18671897

18681898
def validate_kcals_consumed(self):

apps/baseline/serializers.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
from django.db.models import F, FloatField, Sum, Value
1+
from django.db.models import F, FloatField, Sum
22
from django.db.models.functions import Coalesce
33
from rest_framework import serializers
44
from rest_framework_gis.serializers import GeoFeatureModelSerializer
55

66
from common.fields import translation_fields
77
from common.serializers import AggregatingSerializer
8-
from metadata.models import WealthGroupCategory
98

109
from .models import (
11-
AnnualKcalsCost,
1210
BaselineLivelihoodActivity,
1311
BaselineWealthGroup,
1412
BaselineWealthGroupCharacteristicValue,
@@ -77,6 +75,8 @@ class Meta:
7775

7876

7977
class LivelihoodZoneBaselineSerializer(serializers.ModelSerializer):
78+
annual_kcals_cost = serializers.FloatField(read_only=True)
79+
8080
class Meta:
8181
model = LivelihoodZoneBaseline
8282
fields = (
@@ -100,6 +100,7 @@ class Meta:
100100
"valid_to_date",
101101
"population_source",
102102
"population_estimate",
103+
"annual_kcals_cost",
103104
)
104105

105106
livelihood_zone_name = serializers.CharField(source="livelihood_zone.name", read_only=True)
@@ -113,6 +114,8 @@ def get_bss_language(self, obj):
113114

114115

115116
class LivelihoodZoneBaselineGeoSerializer(GeoFeatureModelSerializer):
117+
annual_kcals_cost = serializers.FloatField(read_only=True)
118+
116119
class Meta:
117120
model = LivelihoodZoneBaseline
118121
fields = (
@@ -137,6 +140,7 @@ class Meta:
137140
"valid_to_date",
138141
"population_source",
139142
"population_estimate",
143+
"annual_kcals_cost",
140144
)
141145
geo_field = "geography"
142146
auto_bbox = True
@@ -1721,9 +1725,7 @@ class Meta:
17211725
Coalesce(F("income"), 0)
17221726
/ (
17231727
F("wealth_group__average_household_size")
1724-
* AnnualKcalsCost(
1725-
F("wealth_group__livelihood_zone_baseline_id"), Value(WealthGroupCategory.POOR)
1726-
)
1728+
* F("wealth_group__livelihood_zone_baseline___annual_kcals_cost")
17271729
)
17281730
)
17291731
),
@@ -1734,7 +1736,7 @@ class Meta:
17341736
(
17351737
Coalesce(F("percentage_kcals"), 0)
17361738
* F("wealth_group__average_household_size")
1737-
* AnnualKcalsCost(F("wealth_group__livelihood_zone_baseline_id"), Value(WealthGroupCategory.POOR))
1739+
* F("wealth_group__livelihood_zone_baseline___annual_kcals_cost")
17381740
)
17391741
+ Coalesce(F("income"), 0)
17401742
),

0 commit comments

Comments
 (0)