cleaned v0.7.0Reviewer role: sustainable land-use and agro-environmental modeller (adversarial review)
Source reviewed (read-only): CIAT/cleaned @ 800d53a (v0.7.0)
Scope: land_requirement.R, land_productivity.R, biomass_calculation.R, water_requirement.R, soil_health.R, soc.R, nitrogen_balance.R, compare_scenario.R, differences.R, merge_outputs.R, inst/extdata/*.json, vignettes, README.
The land, water, erosion, N-balance and SOC modules should not be used for quantitative ex-ante claims yet. The land footprint silently drops feeds (any Inf/NaN area becomes 0), so up to 74% of the ration's dry matter got no land in a test run. It is also inflated by up to about 70% because feed demand is set to the larger of the energy-based and protein-based DMI. The N balance adds per-hectare terms to farm totals, has a scalar ifelse bug that throws away grazing-manure N, silently ignores fertiliser types missing from the fertiliser table, and calls outputs/inputs including losses "NUE". Water is reported in mm·ha labelled as m³ (10x too low), with no green/blue split and no season length. SOC applies IPCC factors to a measured stock of arbitrary depth, so any baseline shows a permanent loss, and tree-SOC is always zero. There is no uncertainty treatment. The scenario % changes flip sign for negative baselines and report 0% for undefined ones.
inst/extdata/example_input.json. It also fails on data/qt_example.json and on data(mufindi), because crop_name is missing (see D-01).crop_namedry_yield/residue_dry_yield from fresh_yield, harvest_index and water_content using the commented formula at land_requirement.R:84-88fat_milkcontent, cp_lys_* and n_manure_contentpercentage_n = 34 where it was missingexample_input.json (Muheza) and "QT" = data/qt_example.json. The numbers quoted below come from these runs.| id | severity | verdict | file:line | issue |
|---|---|---|---|---|
| LU-01 | Critical | CONFIRMED | land_requirement.R:99-102, 120-121 | Division by zero (removal=0, yield=0, source_type not Main/Residue) gives Inf/NaN, which is then set to 0 ha. Feeds silently get no land (74% of DM in QT). |
| LU-02 | High | CONFIRMED | energy_requirement.R:174; land_requirement.R:93 | Land is driven by dmi_s = max(DMI_energy, DMI_CP). CP-limited diets inflate DM demand and land by 27-69% in the examples. |
| LU-03 | Medium | PLAUSIBLE | land_requirement.R:103-107 | Residue land uses undocumented mass allocation. Main items take 100% of crop land even when their residue is also fed, so there is a double-count risk. No economic allocation. |
| LU-04 | Medium | CONFIRMED | land_requirement.R:108-110; water_requirement.R:53-55; nitrogen_balance.R:269-298; merge_outputs.R:602-604 | Off-farm/imported status is inferred from the substrings "OFR"/"OFC"/"IP" in feed names. Off-farm land is then scored with on-farm soil and climate. |
| LU-05 | Low | PLAUSIBLE | land_requirement.R:100-102 | Area is cut by intercrop*intercrop_fraction with no documented meaning. |
| LU-06 | Medium | CONFIRMED | differences.R:211-214; compare_scenario.R:328-342; differences.R:195 vs merge_outputs.R:530 | "ha/kg" indicators are really ha/t (x1000). Milk, meat and protein each get 100% of the land (no co-product allocation). AME uses 2100 vs 2500 kcal in different places. |
| N-01 | Critical | CONFIRMED | nitrogen_balance.R:249-256 | Leaching out3 is a per-ha rate that is never multiplied by area. It also adds total organic N (in2) into a per-ha pool, and is then summed with farm-total terms in nout. |
| N-02 | High | PLAUSIBLE | nitrogen_balance.R:249 | Leaching coefficient written as 0.021*(P-3.9). The De Willigen/NUTMON form is 0.021*P - 3.9, so leaching is about 3.8 percentage points too high (+22% at 1000 mm, +84% at 400 mm). |
| N-03 | High | CONFIRMED | nitrogen_balance.R:234-238 | ifelse(denom > 0, vector, 0) returns only the first element. Grazing-manure N is replaced by feed 1's value for every feed (126.6 kg N lost in QT). |
| N-04 | High | CONFIRMED | nitrogen_balance.R:80-121; example_input.json fertilizer |
A fertiliser missing from para$fertilizer, or with no percentage_n, silently gives 0 N. QT's 400 kg urea/ha becomes 0 N, while GHG manufacturing emissions still count it. |
| N-05 | High | CONFIRMED | nitrogen_balance.R:148-155 | All collected-manure N and purchased organic N is given to every feed x fraction_as_fertilizer, with no normalisation. N is multiplied by the number of feeds (200% in QT). |
| N-06 | High | CONFIRMED | nitrogen_balance.R:257, 285-294 | "NUE" = nout/nin, where nout includes leaching, gaseous and erosion losses. The mining (>0.9) and leaching (<0.5) classes are meaningless (EX NUE = 24 and 54). |
| N-07 | Medium | CONFIRMED | nitrogen_balance.R:164 vs 184 | soil_n is used on two bases 10x apart. The soil N stock (and so mineralisation) is 10x low if soil_n is in g/kg, which is the basis erosion N (out5) assumes. |
| N-08 | Medium | CONFIRMED | nitrogen_balance.R:174, 253-254 | Non-symbiotic fixation 2+(P-1350)*0.005 is negative when P < 950 mm. Gaseous loss can go negative at low clay and low rain. Neither is clamped. |
| N-09 | Medium | PLAUSIBLE | nitrogen_balance.R:143-148; ghg_emission.R:373-375 | The N balance uses excreted N with no storage losses (not consistent with the GHG manure-N chain). The GHG module uses fertiliser N as off-farm residue N. |
| SE-01 | High | CONFIRMED | soil_health.R:35-58, 73; nitrogen_balance.R:179-182 | LS is NA when slope_desc is empty/unknown or slope_length is NA. The NA is later dropped with na.rm, so erosion and erosion-N become 0. EX: all slope_desc = "" gives zero erosion. |
| SE-02 | High | PLAUSIBLE | soil_health.R:26-27 | R = 0.55*(P/rain_length) - 4.7 has no source. It gives about 98-160, roughly 5-10x below common SSA erosivity relations. The units of K are unstated. |
| SE-03 | Medium | PLAUSIBLE | soil_health.R:34-58; example inputs slope_p_factor |
The LS lookup has no source, units or length basis, and uses very wide slope classes. P = 0.11 (terracing level) is the de facto default. |
| SE-04 | High | CONFIRMED | merge_outputs.R:620, 649-665 | erosion_t_soil_year is built in the order total, OFR, OFC, IP, farm but labelled total, on-farm, OFR, OFC, IP. Rows are shifted, and per-ha values use the wrong area. |
| SE-05 | Medium | CONFIRMED | merge_outputs.R:628-634; differences.R:231 | balance_N_kg_N_ha sums per-ha values across feeds. The headline N balance uses the whole-crop balance in one output and the feed-only balance in another. |
| W-01 | High | CONFIRMED | water_requirement.R:48-49, 57, 74-81; differences.R:352-376 | ET in mm x area in ha is reported as "m3" and "m3/ha" (1 mm·ha = 10 m³), so values are 10x too low. |
| W-02 | High | CONFIRMED | water_requirement.R:33-49 | Annual ET0 x mean Kc is applied over 365 days for every crop. No growing season, effective rainfall, irrigation, green/blue split or drinking water. The wfp_green/blue/grey and livestock water_requirement inputs are unused. |
| W-03 | Medium | CONFIRMED | water_requirement.R:49-51, 74 | Residue water is allocated twice (already-allocated area_feed x feed share again). total_water_use does not equal the sum of feed_water_use (QT test: 307 vs 1460 mm/ha). |
| W-04 | Medium | CONFIRMED | water_requirement.R:56 | kc_water_use_on_farm subtracts roughages and IP but not OFC concentrates. |
| W-05 | Low | CONFIRMED | water_requirement.R:46 | Kc is the plain mean of the three stages, not a stage-duration-weighted mean (FAO-56). |
| S-01 | High | CONFIRMED | soc.R:234; biomass_calculation.R:141-144 | biomass[["trees_non_feed_biomass"]] is NULL (biomass is a plain data frame), so tree-SOC is always 0. |
| S-02 | High | CONFIRMED | soc.R:103-133 | IPCC F factors are applied to the measured field SOC as if it were SOCref. Long-term, steady-state cropland therefore shows a permanent loss (QT: -17 t C/ha/yr). |
| S-03 | High | CONFIRMED | soc.R:73-74; input-contract.Rmd:53 | soil_depth is implicitly in metres, with no 0-30 cm basis and no validation. EX (depth 2) gives 832 t C/ha; QT (BD = 6) gives 1440 t C/ha. |
| S-04 | High | CONFIRMED | stock_change_parameters.json grassland.management."Medium": 1.5 |
This management factor is not in IPCC. It gives +50% SOC over 20 years (about +1 t C/ha/yr on the fixed 40 t C). |
| S-05 | Medium | CONFIRMED | soc.R:106-107, 141-142, 175-176 | "Cropland" area includes off-farm and imported concentrate land at farm SOC. Grassland SOC is fixed at 40 t C and ignores field_soc. Tree-legume land gets no SOC. |
| S-06 | Low | CONFIRMED | output-contract.Rmd:258-262 vs soc.R:245-248 | The docs promise field_soc and per-land-use changes; the function returns only 2 totals. |
| C-01 | High | CONFIRMED | compare_scenario.R:49-150 (all % blocks) | % change = (S-B)/B. It flips sign for negative baselines (N balance, SOC) and gives 0% when B = 0. |
| C-02 | Medium | CONFIRMED | differences.R:39-40; water_requirement.R:76-81; merge_outputs.R:703-704 | Zero denominators give 0 intensities (for example 0 m³ or 0 kg CO2e per kg meat when there is no meat) instead of NA. |
| C-03 | High | CONFIRMED | all modules (absence) | Everything is deterministic. There is no uncertainty, sensitivity or range output, although results hinge on unobserved inputs (yield, HI, removal, Kc, R, LS, P, soil N). |
| D-01 | High | CONFIRMED | README.md "Standard Model Run"; inst/extdata/example_input.json | The shipped example and README workflow fail (missing crop_name, dry_yield, residue_dry_yield, percentage_n, fat_milkcontent, cp_lys_*, n_manure_content). The derived yields are computed outside the package. |
| D-02 | Medium | CONFIRMED | input-contract.Rmd:42-53, 146; example_input.json | Units are given only as the "package basis". dm_content is a fraction (0.3) in EX but a percent (89) in QT and in the README. No range checks (BD = 6, depth = 2). |
Counts: Critical 2, High 18, Medium 13, Low 3 (36 in total).
LU-01 - Silent zero land for feeds (Critical, CONFIRMED)
area_total = feed_item_dm/(crop_yield*crop_removal) ... for source_type "Main", and feed_item_dm/(cr_yield*crop_residue_removal) for "Residue". Any other source_type gets 0 (L99-102).mutate_if(is.numeric, list(~na_if(.,Inf))) %>% replace(is.na(.), 0) (L120-121).main_product_removal=0, 7,480 kg DM) and rice straw (Residue, residue_dry_yield=0, 7,203 kg DM) both get 0 ha. That is 74% of the 19,765 kg DM ration. Only oats get land (0.169 ha).residue_removal=0, 2,043 kg DM, 12% of DM) gets 0 ha. "Concentrate (commercial)" has source_type "Purchased", so it always gets 0 ha.feed_item_dm > 0 and the denominator is ≤ 0.LU-02 - Land driven by the larger of energy- and CP-based DMI (High, CONFIRMED mechanism)
dmi_s = ifelse(dmi_required_cp>dmi_required_e, dmi_required_cp, dmi_required_e) (energy_requirement.R:174), then feed_item_dm = fraction_dry_matter*dmi_s (land_requirement.R:93).LU-03 - Allocation of crop land between food and residue feed (Medium, PLAUSIBLE)
area_feed = area_total * cr_yield*res_removal/(crop_yield*crop_removal + cr_yield*res_removal) (L103-107). This is DM-mass allocation.crop_residue_removal is forced to 0 (L97-98), so a grain or fodder item takes 100% of its crop's area.residue_dry_yield=5: straw gets 21% of 1.8 ha.LU-04 - Feed-source classification by name substring (Medium, CONFIRMED)
str_detect(feed, "OFR"), "OFC", "IP" in land_requirement.R:108-116 and repeated in the water, N, merge and GHG code. A name like "…PIPER…" or "SHIP…" would be classed as imported.annual_prec, soil_*, R, K, et, cropland_system and so on (soil_health.R:26-27; water_requirement.R:33; soc.R:106).feed_origin field. Use separate (or default regional) soil and climate parameters for off-farm land, or report off-farm land as area only.LU-05 - Intercropping reduction (Low, PLAUSIBLE)
area*(1 - intercrop*intercrop_fraction) (L100-102). Whether yields are sole-crop or intercrop yields, and whether the "saved" land belongs to the companion crop, is undocumented.LU-06 - Land intensity metrics (Medium, CONFIRMED)
total_land_requirement_ha_per_kg_fpcm <- safe_div(ha, kg)*1000 (differences.R:211) is ha per tonne, but it is plotted as "ha/kg" (compare_scenario.R:328-342).land_productivity() computes no land productivity at all. It returns livestock outputs.N-01 - Leaching term mixes per-ha and total units (Critical, CONFIRMED)
out3a/b/c = (n_mineralized_kg_ha_year + fertilizer_rate + in2) * (...)/100 (L249-252).n_mineralized and fertilizer_rate are kg/ha, but in2 = organic_n_kg_total is kg per farm (L245).out3 is never multiplied by area_total, yet nout = out1+out2+out3+out4+out5 adds it to farm totals (L256).out4 uses organic_n_kg_per_ha and * area_total (L253-254).out3 = 2.55 kg for every feed, including zero-area feeds.out3 = 46.4 kg total, where the correct value is about 0.169 x the per-ha rate using the per-ha manure.out3 = (Nmin + Nfert + Norg_per_ha) x frac x area_total, as out4 already does.nbalance, "NUE", the leaching area and the per-ha summaries.organic_n_kg_per_ha and multiply by area_total.N-02 - Leaching regression mis-parenthesised (High, PLAUSIBLE)
0.021 * (annual_precipitation - 3.9) (L249), while the other clay classes use the form a*P + b (0.014*P + 0.71, 0.0071*P + 5.4).0.021*P - 3.9 (%) for clay < 35%.N-03 - Scalar ifelse discards grazing-manure N (High, CONFIRMED)
animal_manure_grazing = ifelse(denom > 0, sum_n_content_manure_grazing*(...)/denom, 0) (L234-238). denom is a scalar, so ifelse returns only element 1 of the vector, recycled to every row. Checked: ifelse(5082>0, c(0,126.6,0), 0) returns 0.if (denom > 0) ... else 0 (vector form), and allocate grazing manure only to on-farm grazed areas.N-04 - Missing fertiliser types or N content silently give 0 N (High, CONFIRMED)
ifelse(feed_selected$urea == 0, 0, as.numeric(para$fertilizer[which(desc=="Urea"),]$percentage_n)/100), then !is.finite(...) becomes 0 (L116-120). If "Urea" is not in para$fertilizer, or percentage_n is absent (as in example_input.json, whose fertiliser row has no percentage_n), the N input is 0.in1 = 0.85 kg total (only N-solutions counted). Adding a Urea row gives in1 = 32.0 kg, so about 184 kg N/ha was ignored.fertilizer_rate from n_balance) does not.N-05 - Manure and purchased organic N multiplied across feeds (High, CONFIRMED)
animal_manure_collected <- n_content_manure_collected * manure_fraction and organic_n_imported <- manure_fraction*(purchased_...) inside the per-feed loop (L148-155). There is no division by the sum of fractions and no area weighting.fraction_as_fertilizer = 1 each), so 200% of the available manure N is applied. organic_n_kg_per_ha = 488 kg N/ha on oats.fraction_as_fertilizer to sum to ≤ 1 across feeds (with a warning), and optionally distribute by area.N-06 - "NUE" is not NUE (High, CONFIRMED)
nue = nout/nin (L257), where nout includes leaching, gaseous loss and erosion. Mining is nue > 0.9 and leaching is nue < 0.5 (L285-294).nue = (out1+out2)/nin, and keep the balance separate.N-07 - Two bases for soil_n (Medium, CONFIRMED)
ntot_kg_ha_20cm = soil_n*20*soil_bulk*10 (L164). If soil_n is in g/kg, the correct stock is soil_n*0.2 m*1e4 m²*BD = 2000*BD*soil_n kg/ha, so the code is 10x low. If it is in %, the code is 100x low.out5 = soil_loss_plot(t)*soil_n*1.5 (L184) is kg N only if soil_n is in g/kg.N-08 - Negative inputs and losses (Medium, CONFIRMED)
in4a = 2 + (P-1350)*0.005 (Smaling-type relation) is < 0 for P < 950 mm, which covers much of semi-arid SSA.out4 fraction -9.4 + 0.13*clay + 0.01*P is < 0 for, for example, clay 20% and P 600 mm.pmax(0, …).N-09 - Cross-module N consistency (Medium, PLAUSIBLE)
n_content_manure_collected) with no housing or storage losses. The GHG module models NH3 and N2O losses from the same manure, so the same N is both lost and fully applied.conc_of_n_from_crop_residue_managed_soil <- sum(nitrogen_balance$conc_of_min_fert_n): fertiliser N is used as residue N.nitrogen_balance$rough_of_n_from_crop_residue, which does not exist, so it returns 0.main_n as a fraction of DM, while n_fixing uses 0.5 x shoot N (no roots, with a fixed %Ndfa). Both are undocumented.SE-01 - Erosion silently zero when slope inputs are missing (High, CONFIRMED)
ifelse returns NA_real_ for any unmatched class (L58), so soil_loss_ha_year is NA.n_balance then uses sum(..., na.rm=TRUE), which gives 0 (nitrogen_balance.R:179-182), and summaries do the same.slope_desc: "", so LS = NA and erosion = 0 with no warning. QT has slope_length 0, which is accepted as the class "≤1".slope_desc against the allowed set, and stop or warn on NA.SE-02 - Rainfall erosivity formula (High, PLAUSIBLE)
R = 0.55*(annual_prec/rain_length) - 4.7 (L26) has no reference, and rain_length has no unit (6 and 5 in the examples, apparently months). This gives R = 98 (EX) and 160 (QT).SE-03 - LS table and P default (Medium, PLAUSIBLE)
slope_p_factor = 0.11 appears for every feed in both examples, including pasture and purchased concentrate. That value corresponds to well-maintained terraces; P = 1 for no practice. Erosion is about 9x lower than without conservation practices.SE-04 - Erosion summary rows shifted (High, CONFIRMED)
sources = c("total","on-farm","rough of","conc of","conc ip") (merge_outputs.R:620).erosion_t_soil_year = c(soil_loss_plot, rough_of_soil_loss, conc_of_soil_loss, conc_ip_soil_loss, farm_soil_loss) (L649-655).erosion_t_soil_ha then divides those shifted values by the mislabelled areas (L659-665).SE-05 - Per-ha N summary summed across feeds (Medium, CONFIRMED)
balance_N_kg_N_ha = sum_col(nitrogen_balance, "nbalance_feed_only_kg_n_ha") (merge_outputs.R:628-634) adds kg/ha values across feeds. It should be the total divided by the area.nbalance_kg_n_total (whole crop, including the food share), while merge_outputs uses the feed-only balance. The two headline N balances differ in scope.W-01 - mm·ha reported as m³ (High, CONFIRMED)
ET = kc_frac*et (mm) and water_use = ET*sum(area_feed) (L48-49). kc_water_use_m3_per_ha = feed_water_use/area_feed (L57) is therefore just Kc·ET0 in mm. EX pasture shows 815 "m³/ha" = 0.6 x 1359 mm.total_water_use, water_use_fpcm, _meat, _protein, and total_water_use_m3* in differences.R) is 10x too low.water_use_fpcm = 0.235 "m³/kg" should be 2.35 m³/kg under the model's own assumptions.W-02 - ET method incomplete (High, CONFIRMED)
para$et, annual) x the mean of Kc_ini/mid/late is applied as if every crop transpires all year. There is no crop season length, even though cultivation_period exists in QT feed items.wfp_green/blue/grey fields in feed_items are ignored.livestock$water_requirement) is ignored, even though the function is called water_requirement.fraction_of_precipitation_used_for_feed_production = ET/annual_prec is an area-weighted crop ET/P ratio, not a share of farm rainfall.W-03 - Double allocation for residues (Medium, CONFIRMED)
water_use is already computed on area_feed (the allocated share). Then feed_water_use = water_use*(1 - area_non_feed/area_total) (L50-51) applies the share a second time.total_water_use = ET*sum(area_feed) (L74) does not include the second allocation, so the per-feed and total water outputs disagree.W-04 - OFC included in on-farm water (Medium, CONFIRMED)
kc_water_use_on_farm = feed_water_use - roughages - ip_concentrates (L56) omits kc_water_use_of_concentrates.W-05 - Plain-mean Kc (Low, CONFIRMED)
(kc_initial + kc_midseason + kc_late)/3 (L46). FAO-56 weights each Kc by stage length.S-01 - Tree SOC always zero (High, CONFIRMED)
biomass_calculation() returns a plain tibble (L141-144). soil_organic_carbon() reads biomass[["trees_non_feed_biomass"]]$c_increase_soc (soc.R:234), which is NULL, so the sum is 0. Verified in both runs.differences.R adds biomass$co2_increase (above-ground only), so the 25% below-ground/soil increment is dropped everywhere.S-02 - Permanent SOC loss for steady-state land (High, CONFIRMED)
((SOC*FLU*FMG*FI) - SOC)/20 * area (soc.R:125-132), where SOC is the measured field stock.S-03 - Depth and units (High, CONFIRMED)
soil_amount = 1e6*(soil_depth/100)*soil_bulk = 1e4·depth·BD. This is t/ha only if depth is in metres.soil_depth = 2 gives 2 m and 832 t C/ha. QT soil_bulk = 6 is impossible but accepted, giving 1440 t C/ha.soil_c.S-04 - Non-IPCC grassland factor (High, CONFIRMED)
stock_change_parameters.json has grassland.management."Medium": 1.5. IPCC 2006 Vol 4 Table 6.2 has nominally managed 1.0, moderately degraded 0.95-0.97, severely degraded 0.7 and improved 1.14-1.17. There is no 1.5.S-05 - Land categories (Medium, CONFIRMED)
sum(area_feed) - grasses - tree_legume (L106-107). This includes off-farm and imported concentrate land at farm SOC and farm management.field_soc or IPCC SOCref.S-06 - Doc mismatch (Low, CONFIRMED)
field_soc and per-land-use rows. The function returns only total_annual_change_carbon_soils and total_change_co2_soils.C-01 - % change arithmetic (High, CONFIRMED)
ifelse(!is.finite((S-B)/B*100), 0, (S-B)/B*100) (compare_scenario.R:49-150).(S-B)/|B|, and return NA when B = 0.C-02 - Zero-denominator intensities (Medium, CONFIRMED)
safe_div(..., default = 0) (differences.R:39) and ifelse(!is.finite(...), 0, ...) (water_requirement.R:76-81) turn undefined intensities into 0. A dairy-only farm then shows "0 m³/kg meat" and "0 t CO2e/kg meat".C-03 - No uncertainty or sensitivity (High, CONFIRMED by absence)
soil_n and BD (N-07, S-03).compare_scenario.D-01 - Shipped examples do not run (High, CONFIRMED)
inst/extdata/example_input.json, data/qt_example.json and data(mufindi) at unnest(cols = c(crop_name)).crop_name, it also needs dry_yield, residue_dry_yield (EX), percentage_n (EX), fat_milkcontent, cp_lys_pregnancy, cp_lys_growth, n_manure_content, lw_gain_piglets and proportion_growth_piglets_milk.land_requirement.R:84-88 that did this is commented out. Those derivations therefore cannot be reviewed or tested.dm_content for the main product's DM, which is wrong for residue items.D-02 - Units unstated or inconsistent (Medium, CONFIRMED)
soil_n, soil_c, soil_depth, et, rain_length and slope_length only on the "package basis".dm_content is 0.3 (a fraction) in EX but 89 (a percent) in QT, and the README says percent.stock_change_parameters.json are transcribed correctly and are looked up by explicit key, with a clear error on a missing key (soc.R:39-71).0.14·√P, clay-dependent leaching classes, and the gaseous-loss regression. The N balance also separates off-farm and on-farm sources.soil_n, soil_c, soil_depth, soil_k_value, rain_length, slope_length and et, and what is the source of the R formula and the LS table?dry_yield, residue_dry_yield and crop_name derived, and can that code be moved into the package and tested?
Comments
Comments are GitHub Discussions on peetmate/cleaned-review; sign in with GitHub to post.