hestia-earth-models 0.74.2__py3-none-any.whl → 0.74.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hestia-earth-models might be problematic. Click here for more details.

Files changed (35) hide show
  1. hestia_earth/models/config/Cycle.json +23 -0
  2. hestia_earth/models/config/ImpactAssessment.json +22 -11
  3. hestia_earth/models/cycle/aboveGroundCropResidueTotal.py +50 -0
  4. hestia_earth/models/cycle/completeness/freshForage.py +2 -2
  5. hestia_earth/models/emepEea2019/co2ToAirFuelCombustion.py +2 -27
  6. hestia_earth/models/emepEea2019/fuelCombustion_utils.py +107 -0
  7. hestia_earth/models/emepEea2019/n2OToAirFuelCombustionDirect.py +2 -27
  8. hestia_earth/models/emepEea2019/nh3ToAirFuelCombustion.py +33 -0
  9. hestia_earth/models/emepEea2019/noxToAirFuelCombustion.py +2 -27
  10. hestia_earth/models/emepEea2019/so2ToAirFuelCombustion.py +2 -27
  11. hestia_earth/models/emepEea2019/utils.py +1 -73
  12. hestia_earth/models/hestia/landOccupationDuringCycle.py +264 -0
  13. hestia_earth/models/hestia/management.py +61 -11
  14. hestia_earth/models/ipcc2006/aboveGroundCropResidueTotal.py +20 -11
  15. hestia_earth/models/ipcc2019/aboveGroundCropResidueTotal.py +37 -28
  16. hestia_earth/models/ipcc2019/animal/pastureGrass.py +6 -4
  17. hestia_earth/models/mocking/search-results.json +704 -704
  18. hestia_earth/models/utils/cropResidue.py +5 -0
  19. hestia_earth/models/version.py +1 -1
  20. {hestia_earth_models-0.74.2.dist-info → hestia_earth_models-0.74.4.dist-info}/METADATA +1 -1
  21. {hestia_earth_models-0.74.2.dist-info → hestia_earth_models-0.74.4.dist-info}/RECORD +35 -28
  22. tests/models/cycle/test_aboveGroundCropResidueTotal.py +20 -0
  23. tests/models/emepEea2019/test_co2ToAirFuelCombustion.py +2 -1
  24. tests/models/emepEea2019/test_n2OToAirFuelCombustionDirect.py +2 -1
  25. tests/models/emepEea2019/test_nh3ToAirFuelCombustion.py +34 -0
  26. tests/models/emepEea2019/test_noxToAirFuelCombustion.py +2 -1
  27. tests/models/emepEea2019/test_so2ToAirFuelCombustion.py +2 -1
  28. tests/models/environmentalFootprintV3_1/test_scarcityWeightedWaterUse.py +0 -1
  29. tests/models/hestia/test_landCover.py +1 -1
  30. tests/models/hestia/test_landOccupationDuringCycle.py +68 -0
  31. tests/models/ipcc2006/test_aboveGroundCropResidueTotal.py +9 -6
  32. tests/models/ipcc2019/test_aboveGroundCropResidueTotal.py +16 -42
  33. {hestia_earth_models-0.74.2.dist-info → hestia_earth_models-0.74.4.dist-info}/LICENSE +0 -0
  34. {hestia_earth_models-0.74.2.dist-info → hestia_earth_models-0.74.4.dist-info}/WHEEL +0 -0
  35. {hestia_earth_models-0.74.2.dist-info → hestia_earth_models-0.74.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,264 @@
1
+ from functools import reduce
2
+ from typing import NamedTuple
3
+
4
+ from hestia_earth.models.log import logRequirements, logShouldRun, log_as_table
5
+
6
+ from hestia_earth.models.utils import hectar_to_square_meter
7
+ from hestia_earth.models.utils.constant import DAYS_IN_YEAR
8
+ from hestia_earth.models.utils.indicator import _new_indicator
9
+ from hestia_earth.models.utils.impact_assessment import get_product
10
+ from hestia_earth.models.utils.site import get_land_cover_term_id as get_landCover_term_id_from_site_type
11
+ from hestia_earth.models.utils.crop import get_landCover_term_id
12
+ from hestia_earth.schema import CycleFunctionalUnit
13
+
14
+ from . import MODEL
15
+
16
+ REQUIREMENTS = {
17
+ "ImpactAssessment": {
18
+ "product": {
19
+ "@type": "Term",
20
+ "value": "> 0",
21
+ "optional": {
22
+ "@doc": "if the [cycle.functionalUnit](https://hestia.earth/schema/Cycle#functionalUnit) = 1 ha, additional properties are required", # noqa: E501
23
+ "economicValueShare": ">= 0"
24
+ }
25
+ },
26
+ "cycle": {
27
+ "@type": "Cycle",
28
+ "site": {
29
+ "@type": "Site",
30
+ "country": {"@type": "Term", "termType": "region"}
31
+ },
32
+ "siteArea": "",
33
+ "siteDuration": "",
34
+ "siteUnusedDuration": "",
35
+ "optional": {
36
+ "@doc": "When `otherSites` are provided, `otherSitesArea`, `otherSitesDuration` and `otherSitesUnusedDuration` are required", # noqa: E501
37
+ "otherSites": [{
38
+ "@type": "Site",
39
+ "country": {"@type": "Term", "termType": "region"}
40
+ }],
41
+ "otherSitesArea": "",
42
+ "otherSitesDuration": "",
43
+ "otherSitesUnusedDuration": ""
44
+ }
45
+ }
46
+ }
47
+ }
48
+ RETURNS = {
49
+ "Indicator": [{
50
+ "value": "",
51
+ "landCover": ""
52
+ }]
53
+ }
54
+ TERM_ID = 'landOccupationDuringCycle'
55
+
56
+
57
+ class SiteData(NamedTuple):
58
+ id: str # site.@id
59
+ area: float
60
+ duration: float
61
+ unused_duration: float
62
+ land_cover_id: str
63
+ country_id: str
64
+
65
+
66
+ def _indicator(term_id: str, value: float, land_cover_id: str, country_id: str):
67
+ indicator = _new_indicator(
68
+ term_id, model=MODEL, land_cover_id=land_cover_id, country_id=country_id
69
+ )
70
+ indicator['value'] = round(value, 6)
71
+ return indicator
72
+
73
+
74
+ def _calc_land_occupation_m2_per_ha(
75
+ site_area: float, site_duration: float, site_unused_duration: float
76
+ ) -> float:
77
+ """
78
+ Parameters
79
+ ----------
80
+ site_area : float
81
+ Area of the site in hectares.
82
+ site_duration : float
83
+ Site duration in days.
84
+ site_unused_duration : float
85
+ Site unused duration in days.
86
+
87
+ Returns
88
+ -------
89
+ float
90
+ """
91
+ return hectar_to_square_meter(site_area) * (site_duration + site_unused_duration) / DAYS_IN_YEAR
92
+
93
+
94
+ def _calc_land_occupation_m2_per_kg(
95
+ yield_: float, economic_value_share: float, land_occupation_m2_per_ha: float,
96
+ ) -> float:
97
+ """
98
+ Parameters
99
+ ----------
100
+ yield_ : float
101
+ Product yield in product units.
102
+ economic_value_share : float
103
+ Economic value share of the product in % (0-100).
104
+ land_occupation_m2_per_ha : float
105
+ Land occupation in m2 ha-1.
106
+
107
+ Returns
108
+ -------
109
+ float
110
+ """
111
+ return land_occupation_m2_per_ha * economic_value_share * 0.01 / yield_
112
+
113
+
114
+ _CYCLE_KEYS = (
115
+ "site", "siteArea", "siteDuration", "siteUnusedDuration"
116
+ )
117
+
118
+ _CYCLE_KEY_MAPPING = {
119
+ field: field.replace("site", "otherSites", 1) for field in _CYCLE_KEYS
120
+ }
121
+
122
+
123
+ def _build_inventory(cycle: dict, product: dict):
124
+ product_land_cover_id = get_landCover_term_id(product.get("term", {}), skip_debug=True)
125
+
126
+ cycle_data = {
127
+ key: [value] + cycle.get(otherSites_key, []) for key, otherSites_key in _CYCLE_KEY_MAPPING.items()
128
+ if (value := cycle.get(key))
129
+ }
130
+
131
+ n_sites = len(cycle_data.get("site", []))
132
+ should_build_inventory = n_sites > 0 and all([
133
+ len(cycle_data.get(key, [])) == n_sites for key in _CYCLE_KEYS[1:]
134
+ ])
135
+
136
+ inventory = [
137
+ SiteData(
138
+ id=site.get("@id"),
139
+ area=cycle_data["siteArea"][i],
140
+ duration=cycle_data["siteDuration"][i],
141
+ unused_duration=cycle_data["siteUnusedDuration"][i],
142
+ country_id=site.get("country", {}).get("@id"),
143
+ land_cover_id=product_land_cover_id or get_landCover_term_id_from_site_type(site.get("siteType", {}))
144
+ ) for i, site in enumerate(cycle_data.get("site", []))
145
+ ] if should_build_inventory else []
146
+
147
+ logs = {
148
+ "n_sites": n_sites
149
+ }
150
+
151
+ return inventory, logs
152
+
153
+
154
+ def _should_run_site_data(site_data: SiteData) -> bool:
155
+ return all([
156
+ site_data.area >= 0,
157
+ site_data.duration >= 0,
158
+ site_data.unused_duration >= 0,
159
+ site_data.land_cover_id,
160
+ site_data.country_id
161
+ ])
162
+
163
+
164
+ def _format_float(value: float, unit: str = "") -> str:
165
+ return " ".join(
166
+ string for string in [f"{value:.3g}", unit] if string
167
+ ) if value else "None"
168
+
169
+
170
+ _INVALID_CHARS = {"_", ":", ",", "="}
171
+ _REPLACEMENT_CHAR = "-"
172
+
173
+
174
+ def _format_str(value: str) -> str:
175
+ """Format a string for logging in a table. Remove all characters used to render the table on the front end."""
176
+ return reduce(lambda x, char: x.replace(char, _REPLACEMENT_CHAR), _INVALID_CHARS, str(value))
177
+
178
+
179
+ def _format_inventory(inventory: list[SiteData]) -> str:
180
+ return log_as_table(
181
+ {
182
+ "@id": _format_str(site_data.id),
183
+ "site-area": _format_float(site_data.area, "ha"),
184
+ "site-duration": _format_float(site_data.duration, "days"),
185
+ "site-unused-duration": _format_float(site_data.unused_duration, "days"),
186
+ "land-cover-id": _format_str(site_data.land_cover_id),
187
+ "country-id": _format_str(site_data.country_id)
188
+ } for site_data in inventory
189
+ ) if inventory else "None"
190
+
191
+
192
+ def _should_run(impact_assessment: dict):
193
+
194
+ cycle = impact_assessment.get("cycle")
195
+ functional_unit = cycle.get("functionalUnit")
196
+
197
+ product = get_product(impact_assessment)
198
+ yield_ = sum(product.get("value", []))
199
+ economic_value_share = (
200
+ 100 if functional_unit == CycleFunctionalUnit.RELATIVE.value
201
+ else product.get("economicValueShare")
202
+ )
203
+
204
+ inventory, logs = _build_inventory(cycle, product)
205
+
206
+ valid_inventory = inventory and all(_should_run_site_data(site_data) for site_data in inventory)
207
+
208
+ logRequirements(
209
+ impact_assessment,
210
+ model=MODEL,
211
+ term=TERM_ID,
212
+ functional_unit=functional_unit,
213
+ yield_=_format_float(yield_, product.get("term", {}).get("units")),
214
+ economic_value_share=_format_float(economic_value_share, "pct"),
215
+ site_inventory=_format_inventory(inventory),
216
+ valid_inventory=valid_inventory,
217
+ **logs
218
+ )
219
+
220
+ should_run = all([
221
+ yield_ > 0,
222
+ (
223
+ economic_value_share is not None
224
+ and economic_value_share >= 0
225
+ ),
226
+ valid_inventory
227
+ ])
228
+
229
+ logShouldRun(impact_assessment, MODEL, TERM_ID, should_run)
230
+
231
+ return should_run, yield_, economic_value_share, inventory
232
+
233
+
234
+ def _run(
235
+ yield_: float,
236
+ economic_value_share: float,
237
+ inventory: list[SiteData]
238
+ ) -> list[dict]:
239
+
240
+ def calc_occupation_by_group(result: dict, site_data: SiteData):
241
+ """Calculate the land occupation of a site and sum it with matching landCover/country groups."""
242
+
243
+ land_occupation_m2_per_ha = _calc_land_occupation_m2_per_ha(
244
+ site_data.area, site_data.duration, site_data.unused_duration
245
+ )
246
+
247
+ land_occupation_m2_per_kg = _calc_land_occupation_m2_per_kg(
248
+ yield_, economic_value_share, land_occupation_m2_per_ha
249
+ )
250
+
251
+ key = (site_data.land_cover_id, site_data.country_id)
252
+ return result | {key: result.get(key, 0) + land_occupation_m2_per_kg}
253
+
254
+ land_occupation_by_group = reduce(calc_occupation_by_group, inventory, {})
255
+
256
+ return [
257
+ _indicator(TERM_ID, value, land_cover_id, country_id)
258
+ for (land_cover_id, country_id), value in land_occupation_by_group.items()
259
+ ]
260
+
261
+
262
+ def run(impact_assessment: dict):
263
+ should_run, yield_, economic_value_share, inventory = _should_run(impact_assessment)
264
+ return _run(yield_, economic_value_share, inventory) if should_run else []
@@ -75,7 +75,8 @@ LOOKUPS = {
75
75
  "organicFertiliser": "inputGapFillManagementTermId",
76
76
  "soilAmendment": "inputGapFillManagementTermId",
77
77
  "landUseManagement": "GAP_FILL_TO_MANAGEMENT",
78
- "property": "GAP_FILL_TO_MANAGEMENT"
78
+ "property": "GAP_FILL_TO_MANAGEMENT",
79
+ "landCover": "sumIs100Group"
79
80
  }
80
81
  MODEL_KEY = 'management'
81
82
 
@@ -241,9 +242,7 @@ def _cycle_has_existing_non_cover_land_cover_nodes(cycle: dict) -> bool:
241
242
  ])
242
243
 
243
244
 
244
- def _run_from_siteType(site: dict, cycle: dict):
245
- site_type = site.get('siteType')
246
- site_type_id = get_landCover_term_id_from_site_type(site_type)
245
+ def _run_from_siteType(cycle: dict, site_type_id: str):
247
246
  start_date = cycle.get('startDate') or _gap_filled_start_date(
248
247
  cycle=cycle,
249
248
  end_date=cycle.get('endDate'),
@@ -261,15 +260,61 @@ def _run_from_siteType(site: dict, cycle: dict):
261
260
  }] if should_run else []
262
261
 
263
262
 
264
- def _should_run_practice(practice: dict):
263
+ def _node_with_gap_filled_dates(node: dict, cycle: dict, site_type_id: str) -> dict:
264
+ return node | {
265
+ "endDate": node.get("endDate") or cycle.get("endDate"),
266
+ "startDate": node.get("startDate") or cycle.get('startDate') or _gap_filled_start_date(
267
+ cycle=cycle,
268
+ end_date=cycle.get('endDate'),
269
+ land_cover_id=site_type_id
270
+ ).get("startDate"),
271
+ }
272
+
273
+
274
+ def _dates_overlap(target_practice: dict, node: dict, cycle: dict, site_type_id: str) -> bool:
275
+ target_practice = _node_with_gap_filled_dates(node=target_practice, cycle=cycle, site_type_id=site_type_id)
276
+ node = _node_with_gap_filled_dates(node=node, cycle=cycle, site_type_id=site_type_id)
277
+ return all([
278
+ node["startDate"],
279
+ node["endDate"],
280
+ target_practice["startDate"],
281
+ target_practice["endDate"],
282
+ (
283
+ node["startDate"] <= target_practice["startDate"] <= node["endDate"] or
284
+ node["startDate"] < target_practice["endDate"] <= node["endDate"]
285
+ )
286
+ ])
287
+
288
+
289
+ def _should_run_practice(management_nodes: list, cycle: dict, site_type_id: str):
265
290
  """
266
291
  Include only landUseManagement practices where GAP_FILL_TO_MANAGEMENT = True
267
292
  """
268
- term = practice.get('term', {})
269
- return term.get('termType') != TermTermType.LANDUSEMANAGEMENT.value or _should_gap_fill(term)
293
+ landCover_management_nodes = [
294
+ _node_with_gap_filled_dates(node=node, cycle=cycle, site_type_id=site_type_id) | {
295
+ 'sumIs100Group': get_lookup_value(node.get("term", {}), 'sumIs100Group', skip_debug=True, model=MODEL)
296
+ }
297
+ for node in filter_list_term_type(management_nodes, TermTermType.LANDCOVER)
298
+ ]
299
+
300
+ def run(practice: dict):
301
+ term = practice.get('term', {})
302
+ target_group = get_lookup_value(practice.get("term", {}), 'sumIs100Group', skip_debug=True, model=MODEL)
303
+ has_other_land_cover_in_same_group = next((
304
+ True for node in landCover_management_nodes
305
+ if (
306
+ node['sumIs100Group'] == target_group and
307
+ _dates_overlap(target_practice=practice, node=node, cycle=cycle, site_type_id=site_type_id)
308
+ )
309
+ ), None) is not None
310
+ return (
311
+ (term.get('termType') != TermTermType.LANDUSEMANAGEMENT.value or _should_gap_fill(term)) and
312
+ not has_other_land_cover_in_same_group
313
+ )
314
+ return run
270
315
 
271
316
 
272
- def _run_from_practices(cycle: dict):
317
+ def _run_from_practices(site: dict, cycle: dict, site_type_id: str):
273
318
  practices = [
274
319
  _extract_node_value(
275
320
  _include_with_date_gap_fill(
@@ -283,13 +328,18 @@ def _run_from_practices(cycle: dict):
283
328
  completeness_mapping=_PRACTICES_COMPLETENESS_MAPPING
284
329
  )
285
330
  ]
286
- return list(map(_map_to_value, filter(_should_run_practice, practices)))
331
+ management_nodes = site.get("management", [])
332
+ return list(map(_map_to_value, filter(
333
+ _should_run_practice(management_nodes=management_nodes, cycle=cycle, site_type_id=site_type_id), practices
334
+ )))
287
335
 
288
336
 
289
337
  def _run_cycle(site: dict, cycle: dict):
290
338
  inputs = _run_from_inputs(cycle)
291
- site_types = _run_from_siteType(site=site, cycle=cycle)
292
- practices = _run_from_practices(cycle)
339
+ site_type = site.get('siteType')
340
+ site_type_id = get_landCover_term_id_from_site_type(site_type)
341
+ site_types = _run_from_siteType(cycle=cycle, site_type_id=site_type_id)
342
+ practices = _run_from_practices(site=site, cycle=cycle, site_type_id=site_type_id)
293
343
  return [
294
344
  node | {'cycle-id': cycle.get('@id')}
295
345
  for node in inputs + site_types + practices
@@ -7,6 +7,7 @@ from hestia_earth.models.utils.property import get_node_property
7
7
  from hestia_earth.models.utils.completeness import _is_term_type_incomplete
8
8
  from hestia_earth.models.utils.product import _new_product
9
9
  from hestia_earth.models.utils.crop import get_crop_lookup_value
10
+ from hestia_earth.models.utils.cropResidue import sum_above_ground_crop_residue
10
11
  from . import MODEL
11
12
 
12
13
  REQUIREMENTS = {
@@ -58,11 +59,6 @@ def _get_value_dm(product: dict, dm_percent: float):
58
59
  ]) else (yield_dm * slope + intercept * 1000)
59
60
 
60
61
 
61
- def _run(product: dict, dm_property: dict):
62
- value = _get_value_dm(product, safe_parse_float(dm_property.get('value'), default=None))
63
- return [_product(value)] if value is not None else []
64
-
65
-
66
62
  def _should_run_product(product: dict):
67
63
  term_id = product.get('term', {}).get('@id')
68
64
  value = list_sum(product.get('value', [0]))
@@ -79,17 +75,30 @@ def _should_run(cycle: dict):
79
75
  dm_property = get_node_property(products[0], PROPERTY_KEY) if single_crop_product else {}
80
76
  term_type_incomplete = _is_term_type_incomplete(cycle, TERM_ID)
81
77
 
78
+ dm_value = safe_parse_float(dm_property.get('value'), default=None)
79
+ value = _get_value_dm(products[0], dm_value) if single_crop_product else None
80
+
81
+ above_ground_crop_residue = sum_above_ground_crop_residue(cycle)
82
+ is_value_below_sum_above_ground_crop_residue = not not value and value >= above_ground_crop_residue
83
+
82
84
  logRequirements(cycle, model=MODEL, term=TERM_ID,
83
85
  single_crop_product=single_crop_product,
84
86
  nb_products=len(products),
85
87
  dryMatter=dm_property.get('value'),
86
- term_type_cropResidue_incomplete=term_type_incomplete)
87
-
88
- should_run = all([term_type_incomplete, single_crop_product, dm_property])
88
+ term_type_cropResidue_incomplete=term_type_incomplete,
89
+ value=value,
90
+ is_value_below_sum_above_ground_crop_residue=is_value_below_sum_above_ground_crop_residue)
91
+
92
+ should_run = all([
93
+ term_type_incomplete,
94
+ single_crop_product,
95
+ value is not None,
96
+ is_value_below_sum_above_ground_crop_residue
97
+ ])
89
98
  logShouldRun(cycle, MODEL, TERM_ID, should_run)
90
- return should_run, products, dm_property
99
+ return should_run, value
91
100
 
92
101
 
93
102
  def run(cycle: dict):
94
- should_run, products, dm_property = _should_run(cycle)
95
- return _run(products[0], dm_property) if should_run else []
103
+ should_run, value = _should_run(cycle)
104
+ return [_product(value)] if should_run else []
@@ -2,10 +2,11 @@ from hestia_earth.schema import TermTermType
2
2
  from hestia_earth.utils.model import filter_list_term_type
3
3
  from hestia_earth.utils.tools import list_sum
4
4
 
5
- from hestia_earth.models.log import debugValues, logRequirements, logShouldRun, log_as_table
5
+ from hestia_earth.models.log import logRequirements, logShouldRun, log_as_table
6
6
  from hestia_earth.models.utils.completeness import _is_term_type_incomplete
7
7
  from hestia_earth.models.utils.product import _new_product
8
8
  from hestia_earth.models.utils.property import get_node_property
9
+ from hestia_earth.models.utils.cropResidue import sum_above_ground_crop_residue
9
10
  from .utils import get_yield_dm
10
11
  from . import MODEL
11
12
 
@@ -43,10 +44,14 @@ def _product(value: float):
43
44
  def _product_value(product: dict):
44
45
  term = product.get('term', {})
45
46
  term_id = product.get('term', {}).get('@id')
46
- value = list_sum(product.get('value'))
47
- dm = get_node_property(product, PROPERTY_KEY).get('value', 0)
48
- yield_dm = get_yield_dm(TERM_ID, term) or 0
49
- total = value * dm / 100 * yield_dm
47
+ value = list_sum(product.get('value'), default=None)
48
+ dm = get_node_property(product, PROPERTY_KEY).get('value')
49
+ yield_dm = get_yield_dm(TERM_ID, term)
50
+ total = value * dm / 100 * yield_dm if all([
51
+ value is not None,
52
+ dm is not None,
53
+ yield_dm is not None,
54
+ ]) else None
50
55
  return {
51
56
  'id': term_id,
52
57
  'value': value,
@@ -56,38 +61,42 @@ def _product_value(product: dict):
56
61
  }
57
62
 
58
63
 
59
- def _run(cycle: dict, products: list):
60
- values = list(map(_product_value, products))
61
- debugValues(cycle, model=MODEL, term=TERM_ID,
62
- details=log_as_table(values))
63
- value = sum([value.get('total', 0) for value in values])
64
- return [_product(value)]
65
-
66
-
67
- def _should_run_product(product: dict):
68
- term = product.get('term', {})
69
- value = list_sum(product.get('value', [0]))
70
- prop = get_node_property(product, PROPERTY_KEY).get('value')
71
- yield_dm = get_yield_dm(TERM_ID, term)
72
- return all([value > 0, prop, yield_dm is not None])
64
+ def _should_run_product(value: dict): return value.get('total') is not None
73
65
 
74
66
 
75
67
  def _should_run(cycle: dict):
68
+ term_type_incomplete = _is_term_type_incomplete(cycle, TERM_ID)
69
+
76
70
  # filter crop products with matching data in the lookup
77
71
  products = filter_list_term_type(cycle.get('products', []), [TermTermType.CROP, TermTermType.FORAGE])
78
- products = list(filter(_should_run_product, products))
79
- has_crop_forage_products = len(products) > 0
80
- term_type_incomplete = _is_term_type_incomplete(cycle, TERM_ID)
72
+ values = list(map(_product_value, products))
73
+ valid_values = list(filter(_should_run_product, values))
74
+
75
+ has_crop_forage_products = len(valid_values) > 0
76
+
77
+ value = list_sum([(value.get('total') or 0) for value in valid_values], default=None)
78
+
79
+ above_ground_crop_residue = sum_above_ground_crop_residue(cycle)
80
+ is_value_below_sum_above_ground_crop_residue = not not value and value >= above_ground_crop_residue
81
81
 
82
82
  logRequirements(cycle, model=MODEL, term=TERM_ID,
83
83
  has_crop_forage_products_with_dryMatter=has_crop_forage_products,
84
- term_type_cropResidue_incomplete=term_type_incomplete)
85
-
86
- should_run = all([term_type_incomplete, has_crop_forage_products])
84
+ term_type_cropResidue_incomplete=term_type_incomplete,
85
+ sum_above_ground_crop_residue=above_ground_crop_residue,
86
+ value=value,
87
+ is_value_below_sum_above_ground_crop_residue=is_value_below_sum_above_ground_crop_residue,
88
+ details=log_as_table(values))
89
+
90
+ should_run = all([
91
+ term_type_incomplete,
92
+ has_crop_forage_products,
93
+ value is not None,
94
+ is_value_below_sum_above_ground_crop_residue
95
+ ])
87
96
  logShouldRun(cycle, MODEL, TERM_ID, should_run)
88
- return should_run, products
97
+ return should_run, value
89
98
 
90
99
 
91
100
  def run(cycle: dict):
92
- should_run, products = _should_run(cycle)
93
- return _run(cycle, products) if should_run else []
101
+ should_run, value = _should_run(cycle)
102
+ return [_product(value)] if should_run else []
@@ -188,7 +188,9 @@ def calculate_NEwool(cycle: dict, animal: dict, products: list, total_weight: fl
188
188
  return total_energy * animal_weight/total_weight
189
189
 
190
190
 
191
- def _run_practice(animal: dict, values: dict, meanDE: float, meanECHHV: float, REM: float, REG: float, NEwool: float):
191
+ def _run_practice(
192
+ cycle: dict, animal: dict, values: dict, meanDE: float, meanECHHV: float, REM: float, REG: float, NEwool: float
193
+ ):
192
194
  NEm_feed, NEg_feed, log_feed = calculate_NEfeed(animal)
193
195
 
194
196
  def run(practice: dict):
@@ -225,7 +227,7 @@ def _run_practice(animal: dict, values: dict, meanDE: float, meanECHHV: float, R
225
227
  ])
226
228
  has_positive_feed_values = all([NEm_feed >= 0, NEg_feed >= 0])
227
229
 
228
- logRequirements(animal, model=MODEL, term=input_term_id, animalId=animal.get('animalId'), model_key=MODEL_KEY,
230
+ logRequirements(cycle, model=MODEL, term=input_term_id, animalId=animal.get('animalId'), model_key=MODEL_KEY,
229
231
  feed_logs=log_as_table(log_feed),
230
232
  has_positive_feed_values=has_positive_feed_values,
231
233
  animal_logs=logs,
@@ -233,7 +235,7 @@ def _run_practice(animal: dict, values: dict, meanDE: float, meanECHHV: float, R
233
235
  animal_properties=animal_properties)
234
236
 
235
237
  should_run = all([has_positive_feed_values])
236
- logShouldRun(animal, MODEL, input_term_id, should_run, animalId=animal.get('animalId'), model_key=MODEL_KEY)
238
+ logShouldRun(cycle, MODEL, input_term_id, should_run, animalId=animal.get('animalId'), model_key=MODEL_KEY)
237
239
 
238
240
  return _input(input_term_id, value) if should_run else None
239
241
 
@@ -254,7 +256,7 @@ def _run_animal(cycle: dict, meanDE: float, meanECHHV: float, REM: float, REG: f
254
256
  animal_values = get_animal_values(cycle, animal, systems)
255
257
 
256
258
  inputs = non_empty_list(map(
257
- _run_practice(animal, animal_values, meanDE, meanECHHV, REM, REG, NEwool),
259
+ _run_practice(cycle, animal, animal_values, meanDE, meanECHHV, REM, REG, NEwool),
258
260
  practices
259
261
  ))
260
262
  return animal | {