Skip to content

Commit d38bea7

Browse files
authored
Merge pull request #4079 from divine7022/n_ncc_tweaks
Make NCC compost timing crop specific and align both event workflows on the latest version monitoring
2 parents 43de5e9 + 45ffae9 commit d38bea7

9 files changed

Lines changed: 385 additions & 233 deletions

File tree

workflows/fertilization-statewide/01-build-parcel-design.R

Lines changed: 77 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,11 @@ code_lookup <- code_map |>
8888

8989
PEcAn.logger::logger.info(sprintf("Resolved %d CADWR codes via crosswalk", nrow(code_lookup)))
9090

91-
# the event date is anchored to green-up (leafonday) from the gap-filled
92-
# phenology product, observed where the satellite retrieval succeeded and
93-
# crop-calendar filled otherwise, so this covers the full ~600k ag universe
94-
# instead of the ~377k strict-matched subset. crop class per season comes
95-
# from the CADWR Land Use crops product. the crops product's own emergence
96-
# date is empty statewide, so the gap-filled green-up is the only populated
97-
# anchor available.
91+
# the event date is the anchor itself; this workflow applies no offset. the
92+
# anchor transition is chosen per PFT so annuals are timed to planting and
93+
# perennials to leaf-on, matching the split the monitoring event products use.
94+
# both come from the gap-filled LandIQ to MSLSP match, which keys every
95+
# transition by (parcel_id, year, season), so cycles join on a real season key.
9896

9997
years <- config[["years"]]
10098
PEcAn.logger::logger.info("Reading crops and gap-filled phenology for years: ",
@@ -117,50 +115,78 @@ crops <- DBI::dbGetQuery(con, sprintf(
117115
dplyr::rename(year = "yr") |>
118116
dplyr::mutate(code = paste0(.data$CLASS, .data$SUBCLASS))
119117

120-
# the phenology product has no season key, but from 2018 on it carries a second
121-
# green-up for most double-crop parcels, so rank green-ups within a parcel-year
122-
# and match the nth crop cycle to the nth green-up rather than collapsing to the
123-
# earliest. phenology_source is carried through for audit.
124-
phen_anchor_col <- config[["phen_anchor_col"]]
125-
phen_raw <- DBI::dbGetQuery(con, sprintf(
126-
"SELECT * FROM read_parquet('%s') WHERE \"year\" IN (%s)",
118+
pft_anchor <- unlist(config[["pft_anchor"]])
119+
anchor_cols <- sort(unique(pft_anchor))
120+
121+
# "**" is the LandIQ sentinel for subclass not specified. it becomes NA on both
122+
# sides of the join, so those rows land on the class-level fallback
123+
pft_lookup <- readr::read_csv(config[["pft_lookup_path"]],
124+
show_col_types = FALSE) |>
125+
dplyr::filter(!is.na(.data$PFT))
126+
pft_by_code <- pft_lookup |>
127+
dplyr::transmute(CLASS = .data$CLASS,
128+
SUBCLASS = as.integer(dplyr::na_if(as.character(.data$SUBCLASS), "**")),
129+
pft_group = .data$PFT) |>
130+
dplyr::distinct()
131+
pft_by_class <- pft_lookup |>
132+
dplyr::count(.data$CLASS, .data$PFT) |>
133+
dplyr::slice_max(.data$n, n = 1, by = "CLASS", with_ties = FALSE) |>
134+
dplyr::transmute(CLASS = .data$CLASS, pft_group_class = .data$PFT)
135+
136+
phen <- DBI::dbGetQuery(con, sprintf(
137+
"SELECT CAST(parcel_id AS INTEGER) AS parcel_id, CAST(\"year\" AS INTEGER) AS year,
138+
CAST(season AS INTEGER) AS season, gapfill_date_source, %s
139+
FROM read_parquet('%s') WHERE \"year\" IN (%s)",
140+
paste(anchor_cols, collapse = ", "),
127141
file.path(config[["phen_dir"]], config[["phen_glob"]]), yr_list))
128-
phen_id_col <- if ("parcel_id" %in% names(phen_raw)) "parcel_id" else "site_id"
129-
phen_source_col <- if ("phenology_source" %in% names(phen_raw)) {
130-
"phenology_source"
131-
} else if ("gapfill_date_source" %in% names(phen_raw)) {
132-
"gapfill_date_source"
133-
} else {
134-
NA_character_
135-
}
136-
phen <- phen_raw |>
137-
dplyr::transmute(
138-
parcel_id = as.integer(.data[[phen_id_col]]),
139-
year = as.integer(.data$year),
140-
date = as.Date(.data[[phen_anchor_col]]),
141-
phenology_source = if (is.na(phen_source_col)) {
142-
NA_character_
143-
} else {
144-
as.character(.data[[phen_source_col]])
145-
}
146-
) |>
147-
dplyr::filter(!is.na(.data$date)) |>
148-
dplyr::arrange(.data$parcel_id, .data$year, .data$date) |>
149-
dplyr::mutate(phen_rank = dplyr::row_number(), .by = c("parcel_id", "year"))
150142

151-
# where a parcel-year has fewer green-ups than crop cycles, the later cycles
152-
# reuse the last available one
153-
phen_max <- phen |>
154-
dplyr::summarize(max_rank = max(.data$phen_rank), .by = c("parcel_id", "year"))
143+
missing_cols <- setdiff(anchor_cols, names(phen))
144+
if (length(missing_cols) > 0) {
145+
PEcAn.logger::logger.severe(
146+
"phenology product has no column(s) named in pft_anchor: ",
147+
paste(missing_cols, collapse = ", "))
148+
}
155149

150+
# a cycle with no matched phenology row has no anchor, and is dropped rather
151+
# than given a substitute date
156152
plant <- crops |>
157-
dplyr::mutate(season_rank = dplyr::dense_rank(.data$season),
158-
.by = c("parcel_id", "year")) |>
159-
dplyr::inner_join(phen_max, by = c("parcel_id", "year")) |>
160-
dplyr::mutate(phen_rank = pmin(.data$season_rank, .data$max_rank)) |>
161-
dplyr::inner_join(phen, by = c("parcel_id", "year", "phen_rank"))
162-
PEcAn.logger::logger.info(sprintf("Loaded %d cycles across %d parcels (phenology anchored)",
163-
nrow(plant), dplyr::n_distinct(plant$parcel_id)))
153+
dplyr::inner_join(phen, by = c("parcel_id", "year", "season")) |>
154+
dplyr::left_join(pft_by_code, by = c("CLASS", "SUBCLASS")) |>
155+
dplyr::left_join(pft_by_class, by = "CLASS") |>
156+
dplyr::mutate(pft_group = dplyr::coalesce(.data$pft_group, .data$pft_group_class))
157+
PEcAn.logger::logger.info(sprintf(
158+
"Anchored %d of %d crop cycles (%.1f%%) across %d parcels",
159+
nrow(plant), nrow(crops), 100 * nrow(plant) / nrow(crops),
160+
dplyr::n_distinct(plant$parcel_id)))
161+
162+
# non-crop pfts have no anchor rule. report them so a crop type missing a rule
163+
# is visible rather than silently absent
164+
dropped <- plant |>
165+
dplyr::filter(!.data$pft_group %in% names(pft_anchor)) |>
166+
dplyr::count(.data$pft_group, sort = TRUE)
167+
if (nrow(dropped) > 0) {
168+
PEcAn.logger::logger.info(sprintf(
169+
"Dropping %d cycles whose pft has no anchor rule:", sum(dropped$n)))
170+
for (i in seq_len(nrow(dropped))) {
171+
PEcAn.logger::logger.info(sprintf(" %s: %d cycles",
172+
dropped$pft_group[i], dropped$n[i]))
173+
}
174+
}
175+
plant <- plant |> dplyr::filter(.data$pft_group %in% names(pft_anchor))
176+
177+
# index a numeric matrix so the anchor stays config driven, not a branch per pft
178+
anchor_idx <- cbind(seq_len(nrow(plant)),
179+
match(pft_anchor[plant$pft_group], anchor_cols))
180+
anchor_num <- do.call(cbind, lapply(plant[anchor_cols], as.numeric))
181+
plant$date <- as.Date(anchor_num[anchor_idx], origin = "1970-01-01")
182+
183+
# a matched row is expected to carry every transition, so a NULL anchor means the
184+
# product changed rather than a cycle being legitimately undated
185+
no_anchor <- sum(is.na(plant$date))
186+
if (no_anchor > 0) {
187+
PEcAn.logger::logger.severe(sprintf(
188+
"%d cycles have a NULL anchor in the gap-filled product", no_anchor))
189+
}
164190

165191
## subsample
166192
# parcel set is sampled once and applied to all years so the same parcels
@@ -226,16 +252,16 @@ if (nrow(zero_env) > 0) {
226252
}
227253

228254
kept <- design |> dplyr::filter(.data$rate_source == "crosswalk")
229-
src <- kept |> dplyr::count(.data$phenology_source, sort = TRUE)
230-
PEcAn.logger::logger.info("Anchor provenance (phenology_source):")
255+
src <- kept |> dplyr::count(.data$gapfill_date_source, sort = TRUE)
256+
PEcAn.logger::logger.info("Anchor provenance (gapfill_date_source):")
231257
for (i in seq_len(nrow(src))) {
232258
PEcAn.logger::logger.info(sprintf(" %s: %d cycles (%.1f%%)",
233-
src$phenology_source[i], src$n[i],
259+
src$gapfill_date_source[i], src$n[i],
234260
100 * src$n[i] / nrow(kept)))
235261
}
236262

237263
design <- kept |>
238-
dplyr::select("parcel_id", "year", "season", "date", "code",
264+
dplyr::select("parcel_id", "year", "season", "date", "code", "pft_group",
239265
"min_n_lbs_acre", "max_n_lbs_acre") |>
240266
# fixed row order so the per row draws in 02 are reproducible under the seed
241267
dplyr::arrange(.data$parcel_id, .data$year, .data$season)

workflows/fertilization-statewide/README.md

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ Source: California Department of Water Resources. (2016-2023). Statewide Crop Ma
99
Configuration parameters live in `config.yml`. Most setups only need:
1010

1111
- `crops_path`: the harmonized CADWR Land Use crops parquet. Override with `CCMMF_CROPS_PATH`
12-
- `phen_dir`: the gap-filled phenology (green-up) directory. Override with `CCMMF_PHEN_DIR`
13-
- `phen_glob`: file glob under `phen_dir` (default `phenology_statewide_*.parquet`). Override with `CCMMF_PHEN_GLOB`
14-
- `phen_anchor_col`: phenology date column (default `leafonday`). Override with `CCMMF_PHEN_ANCHOR_COL`
12+
- `phen_dir`: the gap-filled LandIQ to MSLSP match directory, the same product the ncc workflow reads. Override with `CCMMF_PHEN_DIR`
13+
- `phen_glob`: file glob under `phen_dir` (default `assigned_year=*_gapfilled.parquet`). Override with `CCMMF_PHEN_GLOB`
14+
- `pft_lookup_path`: crop code to PFT table, the same one the monitoring products use. Override with `CCMMF_PFT_LOOKUP`
15+
- `pft_anchor`: phenology transition used as the anchor, per PFT. See Application timing
1516
- `crosswalk_path`: the CADWR to FREP to UC ANR crop name crosswalk TSV, versioned in this folder
1617
- `output_dir`: output directory for parquet shards
1718
- `n_parcels`, `n_ensemble`, `batch_size`, `workers`: settings per profile
18-
- `nh4_fraction`: share of total synthetic N going to ammonium; the rest goes to nitrate (default 0.5 for a 50/50 split)
19+
- `nh4_fraction`: share of total synthetic N going to ammonium; the rest goes to nitrate (default 1, all ammonium)
1920

2021
# Run
2122

@@ -38,15 +39,32 @@ Events carry no organic C or N: these are synthetic mineral fertilizer applicati
3839

3940
# Known limitations
4041

41-
- The phenology product has no season key, so crop cycles are matched to green-ups by
42-
rank: the nth cycle of a parcel-year takes the nth green-up. From 2018 on the product
43-
carries a second green-up for most double-crop parcels, so this resolves the majority
44-
of them. Where a parcel-year has fewer green-ups than cycles, the later cycles reuse
45-
the last available green-up. 2016 is the exception, carrying one green-up per parcel
46-
year, so its multi-season cycles all share an anchor.
47-
- The event date is the green-up date itself; this workflow applies no offset. Dates
48-
outside the configured crop years occur because the phenology product itself assigns
49-
some green-ups to the previous calendar year.
42+
- A crop cycle with no matched phenology row gets no anchor and is dropped. Against the
43+
gap-filled LandIQ table this is about 99% of cycles.
44+
- The event date is the anchor itself; this workflow applies no offset. Dates outside
45+
the configured crop years occur because the phenology product itself assigns some
46+
transitions to an adjacent calendar year.
47+
48+
# Application timing
49+
50+
The anchor transition is chosen per PFT, so annuals are timed to planting and perennials
51+
to leaf-on. This matches the split the monitoring event products use: they report planting
52+
for annuals and leaf-on for perennials, not both for both.
53+
54+
| PFT | anchor | transition means |
55+
|---|---|---|
56+
| row | `mslsp_OGI` | onset of greenness increase, 15%, used as planting |
57+
| rice | `mslsp_OGI` | as above |
58+
| hay | `mslsp_50PCGI` | 50% greenness increase, leaf-on |
59+
| woody | `mslsp_50PCGI` | as above |
60+
61+
The event date is the anchor itself. Unlike the compost workflow there is no offset window,
62+
because `ca_n_application_rate` is an annual total per crop rather than an application
63+
schedule.
64+
- `ca_n_application_rate` is an annual total per crop, so the whole season's N budget
65+
is applied as one event rather than split across pre-plant, side-dress and
66+
fertigation. The date is therefore a single anchored placeholder for a schedule, not
67+
a measured application date.
5068
- Only crop codes present in the crosswalk resolve to an N rate envelope. Cycles whose
5169
code does not resolve are dropped and reported at run time.
5270

workflows/fertilization-statewide/config.yml

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,33 @@ default:
88
nh4_fraction: 1
99
seed: 42
1010
years: [2016, 2018, 2019, 2020, 2021, 2022, 2023]
11+
# crops is the gap-filled LandIQ table (v4.1.2), the same inventory the
12+
# phenology match is built against. v4.1 is the ungapfilled table and covers
13+
# parcels the match does not.
1114
# large inputs are not distributed with the repo. the defaults resolve on SCC;
1215
# set the matching environment variable to run anywhere else. final home for
1316
# these is pending ccmmf/organization#257
14-
crops_path: !expr path.expand(Sys.getenv("CCMMF_CROPS_PATH", "/projectnb/dietzelab/ccmmf/LandIQ-harmonized-v4.1/crops_all_years.parq"))
15-
phen_dir: !expr path.expand(Sys.getenv("CCMMF_PHEN_DIR", "/projectnb/dietzelab/ccmmf/usr/akash/phen_v2"))
16-
phen_glob: !expr Sys.getenv("CCMMF_PHEN_GLOB", "phenology_statewide_*.parquet")
17-
phen_anchor_col: !expr Sys.getenv("CCMMF_PHEN_ANCHOR_COL", "leafonday")
17+
crops_path: !expr path.expand(Sys.getenv("CCMMF_CROPS_PATH", "/projectnb/dietzelab/ccmmf/LandIQ-harmonized-v4.1.2/crops_all_years.parq"))
18+
# gap-filled LandIQ to MSLSP match, the same product the ncc workflow reads
19+
phen_dir: !expr path.expand(Sys.getenv("CCMMF_PHEN_DIR", "/projectnb/dietzelab/ccmmf/management/phenology/matched_landiq_mslsp_v4.1.2/gapfill_dates"))
20+
phen_glob: !expr Sys.getenv("CCMMF_PHEN_GLOB", "assigned_year=*_gapfilled.parquet")
21+
22+
# crop code to PFT. same table the monitoring products use
23+
pft_lookup_path: !expr path.expand(Sys.getenv("CCMMF_PFT_LOOKUP", "/projectnb/dietzelab/ccmmf/management/LandIQ_cropCode_lookup_table.csv"))
24+
25+
# anchor transition per PFT. annuals are timed to planting and perennials to
26+
# leaf-on, matching the annual/perennial split the monitoring event products
27+
# use: they report planting for annuals and leaf-on for perennials, not both
28+
# for both. the event date is the anchor itself, this workflow applies no offset
29+
pft_anchor:
30+
row: mslsp_OGI
31+
rice: mslsp_OGI
32+
hay: mslsp_50PCGI
33+
woody: mslsp_50PCGI
1834
crosswalk_path: workflows/fertilization-statewide/crop_name_crosswalk.tsv
19-
output_dir: !expr path.expand(Sys.getenv("CCMMF_FERT_OUT", "/projectnb/dietzelab/ccmmf/usr/akash/event_files/fertilization"))
35+
# versioned alongside ncc: v2.0 is the pass built on the v4.1.2 monitoring
36+
# products. bump rather than writing over
37+
output_dir: !expr path.expand(Sys.getenv("CCMMF_FERT_OUT", "/projectnb/dietzelab/ccmmf/usr/akash/event_files/fertilization/v2.0"))
2038
batch_size: 100
2139
workers: 1
2240

0 commit comments

Comments
 (0)