Skip to content

Commit e57fde7

Browse files
authored
Merge branch 'develop' into fix/3882-sa-median-manifest-lookup
2 parents 090dbec + a3e5e9a commit e57fde7

77 files changed

Lines changed: 3647 additions & 387 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Docker build (fork PRs)
2+
3+
# A fast check for Docker build errors introduced by the current PR.
4+
# Implemented separately from the full-stack build in docker.yml because
5+
# pull requests opened from forks get a read-only GITHUB_TOKEN and cannot push
6+
# to ghcr.io/pecanproject, so the normal Docker GHA workflow (docker.yml) skips
7+
# them (see the gate on its `rversion` job). This workflow gives those PRs a
8+
# real "does the image stack still build" signal without pushing anything.
9+
#
10+
# The images build on top of each other (depends -> base -> models -> sipnet).
11+
# The normal workflow runs each as a separate job and pulls the parent back from
12+
# ghcr, which needs a registry we can't push to from a fork. Here we build the
13+
# whole chain in a single job with plain `docker build`, so each image stays in
14+
# the runner's local image store and the next `FROM pecan/<parent>:latest`
15+
# resolves locally. No registry, no push, no secrets.
16+
#
17+
# Scope is the core linear chain only (depends -> base -> models -> sipnet). It
18+
# fits the default runner disk (~19 GB free; the resident stack is ~5-6 GB). The
19+
# other model binaries, baseplus (docs/executor/api) and extras images are not
20+
# built here to keep the job within disk and time budget.
21+
22+
on:
23+
pull_request:
24+
workflow_dispatch:
25+
26+
permissions:
27+
contents: read
28+
29+
jobs:
30+
build-stack:
31+
# Only fork PRs. Same-repo PRs are already covered by docker.yml, which can
32+
# push pr-* tags to ghcr because they run with a writable token.
33+
if: github.event.pull_request.head.repo.full_name != github.repository
34+
runs-on: ubuntu-latest
35+
env:
36+
R_VERSION: "4.4"
37+
steps:
38+
- uses: actions/checkout@v6
39+
40+
- name: Disk space before build
41+
run: df -h /
42+
43+
# NOTE: do not add docker/setup-buildx-action here. Plain `docker build`
44+
# uses the default docker-engine builder, which resolves each
45+
# `FROM pecan/<parent>:latest` from the local image store built by the
46+
# previous step. The docker-container buildx driver would not see those
47+
# local images and every FROM after depends would fail.
48+
- name: Build depends -> base -> models -> sipnet (no push)
49+
run: |
50+
set -euxo pipefail
51+
52+
# depends: FROM rocker/tidyverse:${R_VERSION}
53+
docker build -f docker/depends/Dockerfile \
54+
--build-arg R_VERSION="${R_VERSION}" \
55+
-t pecan/depends:latest \
56+
docker/depends
57+
58+
# base: FROM pecan/depends:latest (resolved from the local store)
59+
docker build -f docker/base/Dockerfile \
60+
-t pecan/base:latest \
61+
.
62+
63+
# models: FROM pecan/base:latest
64+
docker build -f docker/models/Dockerfile \
65+
-t pecan/models:latest \
66+
docker/models
67+
68+
# sipnet: FROM pecan/models:latest
69+
docker build -f models/sipnet/Dockerfile \
70+
--build-arg MODEL_VERSION=git \
71+
-t pecan/model-sipnet-git:latest \
72+
models/sipnet
73+
74+
- name: Disk space after build
75+
if: always()
76+
run: df -h /

.github/workflows/docker.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ jobs:
4242
# As an ugly workaround, we assign it to a job output instead.
4343
# ----------------------------------------------------------------------
4444
rversion:
45+
# Every build job below needs this job, so gating it here skips the whole
46+
# push-based Docker stack for pull requests opened from forks. Forks get a
47+
# read-only GITHUB_TOKEN and cannot push to ghcr.io/pecanproject, so those
48+
# jobs always failed at the push step. Fork PRs are instead built (without
49+
# pushing) by docker-build-pr.yml. Non-fork events (push, merge_group,
50+
# schedule, workflow_dispatch) and same-repo PRs still run the full stack.
51+
if: >-
52+
github.event_name != 'pull_request' ||
53+
github.event.pull_request.head.repo.full_name == github.repository
4554
runs-on: ubuntu-latest
4655
steps:
4756
- id: mon

.github/workflows/integration-test.yml

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,31 @@ on :
55

66
schedule:
77
# run Thursday 4:30 AM UTC
8-
- cron: '30 4 * * 4'
8+
- cron: '30 4 * * 4'
99
jobs:
1010
test:
1111
runs-on: ubuntu-latest
1212

1313
env:
1414
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
15-
16-
container:
15+
PGHOST: postgres
16+
17+
services:
18+
postgres:
19+
image: mdillon/postgis:9.5
20+
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
21+
22+
container:
1723
image: pecan/base:develop
1824

1925
steps:
2026
- name: Checkout source code
2127
uses: actions/checkout@v4
2228

29+
# initialize database
30+
- name: db setup
31+
uses: docker://pecan/db:ci
32+
2333
- name: Run tests
2434
run: |
2535
for FILE in modules/data.atmosphere/inst/integrationTests/*; do
@@ -29,3 +39,6 @@ jobs:
2939
Rscript "$FILE"
3040
fi
3141
done
42+
43+
- name: Run modular pipeline integration test
44+
run: Rscript modules/uncertainty/inst/integrationTests/test.modular.chain.R

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ For more information about this file see also [Keep a Changelog](http://keepacha
1313
with units attached as an attribute.
1414
- New package `PEcAn.RothC` runs the RothC soil carbon model.
1515
- Added `inst/ilamb/` pipeline in PEcAn.benchmark to convert downscaled SDA reanalysis GeoTIFFs into ILAMB-compatible CF netCDF for carbon-cycle benchmarking (#4019).
16+
- Added multi-model benchmarking scripts to `inst/ilamb/` in PEcAn.benchmark: build the CMIP6 and TRENDY ensembles, score the PEcAn reanalysis and its individual SDA members against observational benchmarks over two evaluation windows, and summarize ensemble spread (#4038).
1617
- Added PEcAn.PEPRMT model, including a demo run with example data
1718
- Add `format_try_for_ma()` and `try_trait_mapping()` to `PEcAn.data.remote` to convert trait data from the external TRY database into the tabular format required by the PEcAn meta-analysis module (#3717).
1819
- Add function `qsub_sda()` for submitting SDA batch jobs by splitting a large number of sites into multiple small groups of sites (#3634).
@@ -30,11 +31,13 @@ For more information about this file see also [Keep a Changelog](http://keepacha
3031
- Added `PEcAn.data.land::event_parquet_to_json` for generating PEcAn `event.json` files from well-formatted event parquet files, with support for ensembles of events.
3132

3233
### Fixed
34+
- Docker GHA workflow no longer fails on pull requests opened from forks (#3618).
3335
- Removed unused `grid2netcdf()` from `PEcAn.data.remote` and fixed R CMD check reference notes for `download.LandTrendr.AGB()` (#2758).
3436
- Fixed broken pecanproject.github.io, pecan.gitbooks.io, and other outdated documentation links across book_source, tutorials, models, modules, web, and shiny files (#3710).
3537
- Added note to DEV-INTRO.md documenting Traefik workaround for Apple Silicon (ARM64) Macs: use `traefik:v2.11` with `platform: linux/arm64` to fix 404 errors (#3910)
3638
- Fixed `web/08-finished.php`: show database info instead of "Still running" when workflow folder doesn't exist locally (#3501).
3739
- `PEcAn.utils::transformstats()`: corrected the LSD-to-SE conversion. The previous implementation included an extra `sqrt(n)` factor, causing SE estimates derived from LSD to appear `sqrt(n)` times smaller than they should be, non-conservatively over-weighting those observations in meta-analysis. (#3998)
40+
- `segment_dataframe()` now returns an empty dataframe when date filtering removes all crop-cycle segments, instead of a single row with NA columns that caused downstream segment config errors (#4007).
3841

3942
### Changed
4043
- `PEcAn.uncertainty::get.parameter.samples()`: replaced the `save_to_disk` flag (from #3860) with an `outdir` argument (default `settings$outdir`) controlling whether `samples.Rdata` is written; `outdir = NULL` skips the save. Existing callers are unaffected (@omkarrr2533, #4016)

base/db/R/get_trait_data_pft.R

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
#' @param modeltype character or NULL. Disambiguates PFTs that share a name
2020
#' across model types (e.g. \code{"SIPNET"}, \code{"ED2"}).
2121
#' @param dbcon database connection from \code{\link[PEcAn.DB]{db.open}}.
22-
#' @param trait_names character vector of trait names to retrieve.
22+
#' @param trait_names character vector of trait names to retrieve. If
23+
#' \code{NULL} (default), priors for every trait associated with the PFT
24+
#' are returned and the trait list is derived from those priors. Supply a
25+
#' vector to restrict the query to specific traits.
2326
#' @param constants named list from \code{pft$constants} in the settings.
2427
#' Traits named here are excluded from the returned priors because their
2528
#' values are fixed rather than sampled by the meta-analysis.
@@ -71,16 +74,19 @@
7174
get_trait_data_pft <- function(pft_name,
7275
modeltype,
7376
dbcon,
74-
trait_names,
77+
trait_names = NULL,
7578
constants = list()) {
7679

7780
# ---- Input validation (cheap checks before any DB call) ----
7881
if (!is.character(pft_name) || length(pft_name) != 1L) {
7982
PEcAn.logger::logger.severe("'pft_name' must be a single character string")
8083
}
81-
if (!is.character(trait_names) || length(trait_names) == 0L) {
84+
# trait_names is optional: NULL means "all priors for this PFT". If the
85+
# caller does supply it, it must still be a non-empty character vector.
86+
if (!is.null(trait_names) &&
87+
(!is.character(trait_names) || length(trait_names) == 0L)) {
8288
PEcAn.logger::logger.severe(
83-
"'trait_names' must be a non-empty character vector"
89+
"'trait_names', when supplied, must be a non-empty character vector"
8490
)
8591
}
8692
if (!inherits(dbcon, "DBIConnection")) {
@@ -149,9 +155,13 @@ get_trait_data_pft <- function(pft_name,
149155

150156
# ---- Query prior distributions ----
151157
# format() prevents integer64 from being silently coerced to double in SQL.
158+
# NULL trait_names -> trstr = NULL (NOT vecpaste(NULL), which yields "" and
159+
# would inject an empty `IN ()` clause). query.priors drops the trait filter
160+
# when trstr is NULL and returns every prior for the PFT.
161+
trstr <- if (is.null(trait_names)) NULL else PEcAn.utils::vecpaste(trait_names)
152162
prior_distns <- query.priors(
153163
pft = format(pft_id, scientific = FALSE),
154-
trstr = PEcAn.utils::vecpaste(trait_names),
164+
trstr = trstr,
155165
con = dbcon
156166
)
157167

base/db/man/get_trait_data_pft.Rd

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

base/db/tests/testthat/test-get_trait_data_pft.R

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,31 @@ test_that("prior_distns is a data frame with the required columns", {
116116
expect_true(all(rownames(result$prior_distns) %in% std_traits))
117117
})
118118

119+
test_that("trait_names = NULL returns priors for all of the PFT's traits", {
120+
test_dbcon <- check_db_test()
121+
withr::defer(PEcAn.DB::db.close(test_dbcon))
122+
123+
# trait_names omitted -> NULL -> query.priors returns every prior for the PFT
124+
result_all <- get_trait_data_pft(
125+
pft_name = std_pft,
126+
modeltype = std_modeltype,
127+
dbcon = test_dbcon
128+
)
129+
130+
result_subset <- get_trait_data_pft(
131+
pft_name = std_pft,
132+
modeltype = std_modeltype,
133+
dbcon = test_dbcon,
134+
trait_names = "SLA"
135+
)
136+
137+
expect_s3_class(result_all$prior_distns, "data.frame")
138+
# the unfiltered call must be a superset of any single-trait filter
139+
expect_gte(nrow(result_all$prior_distns), nrow(result_subset$prior_distns))
140+
expect_true(all(rownames(result_subset$prior_distns) %in%
141+
rownames(result_all$prior_distns)))
142+
})
143+
119144
test_that("pft_info contains expected fields and posteriorid is NULL", {
120145
test_dbcon <- check_db_test()
121146
withr::defer(PEcAn.DB::db.close(test_dbcon))
@@ -303,24 +328,3 @@ test_that("errors when query_pfts returns multiple rows (multi-modeltype case)",
303328
"Multiple PFTs"
304329
)
305330
})
306-
307-
test_that("errors when query_pfts returns zero rows", {
308-
empty_record <- data.frame(
309-
id = integer(0),
310-
pft_type = character(0),
311-
name = character(0)
312-
)
313-
mockery::stub(get_trait_data_pft, "query_pfts", empty_record)
314-
315-
fake_dbcon <- structure(list(), class = c("PostgreSQLConnection",
316-
"DBIConnection"))
317-
expect_error(
318-
get_trait_data_pft(
319-
pft_name = "DoesNotExist",
320-
modeltype = "SIPNET",
321-
dbcon = fake_dbcon,
322-
trait_names = "SLA"
323-
),
324-
"PFTs were not found"
325-
)
326-
})

base/workflow/R/run.write.configs.R

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
#' Write model-specific run scripts and configuration files
22
#'
3-
#' @md
43
#' Generates run scripts and configuration files for all analyses (ensemble
54
#' and/or sensitivity analysis) specified in the provided settings. Delegates
65
#' the model-specific config writing to the appropriate `write.config.*`
@@ -43,6 +42,8 @@
4342
#' appended. This forces use of only files within this workflow, to avoid
4443
#' confusion.
4544
#'
45+
#' @md
46+
#'
4647
#' @param settings a PEcAn settings list
4748
#' @param ensemble.size number of ensemble runs
4849
#' @param input_design Input design data.frame coordinating input files across
@@ -53,6 +54,11 @@
5354
#' ensemble and sensitivity analysis (e.g. `post.distns.Rdata`, or
5455
#' `prior.distns.Rdata`).
5556
#' @param overwrite logical: Replace output files that already exist?
57+
#' @param samples Optional pre-computed parameter samples (a list with
58+
#' `trait.samples`, `sa.samples`, `ensemble.samples`). When supplied, these
59+
#' are used directly and `samples.Rdata` is not read from disk. When `NULL`
60+
#' (default), the function falls back to loading `samples.Rdata` from
61+
#' `settings$outdir`.
5662
#'
5763
#' @return The `settings` list (invisibly), updated with ensemble IDs for SA
5864
#' and ensemble analysis (e.g. `settings$sensitivity.analysis$ensemble.id`,
@@ -63,7 +69,7 @@
6369

6470
run.write.configs <- function(settings, ensemble.size, input_design, write = TRUE,
6571
posterior.files = rep(NA, length(settings$pfts)),
66-
overwrite = TRUE) {
72+
overwrite = TRUE, samples = NULL) {
6773

6874
# Validate that input_design matches ensemble.size for ensemble runs
6975
# Note: for SA, ensemble.size is not meaningful; SA design size is determined by
@@ -158,36 +164,43 @@ run.write.configs <- function(settings, ensemble.size, input_design, write = TRU
158164
scipen <- getOption("scipen")
159165
options(scipen = 12)
160166

161-
samples.file <- file.path(settings$outdir, "samples.Rdata")
162-
if (file.exists(samples.file)) {
163-
existing_data <- new.env()
164-
load(samples.file, envir = existing_data) ## loads ensemble.samples, trait.samples, sa.samples, runs.samples, env.samples
165-
trait.samples <- existing_data$trait.samples
166-
sa.samples <- existing_data$sa.samples
167-
168-
# build ensemble.samples only for ensemble runs
169-
# SA runs use sa.samples directly (quantile-based), not ensemble.samples
170-
if ("ensemble" %in% names(settings) &&
171-
!is.null(input_design) &&
172-
"param" %in% colnames(input_design)) {
173-
trait_sample_indices <- input_design[["param"]]
174-
ensemble.samples <- list()
175-
for (pft in names(trait.samples)) {
176-
pft_traits <- trait.samples[[pft]]
177-
ensemble.samples[[pft]] <- as.data.frame(
178-
lapply(
179-
names(pft_traits),
180-
function(trait) pft_traits[[trait]][trait_sample_indices]
181-
)
182-
)
183-
names(ensemble.samples[[pft]]) <- names(pft_traits)
184-
}
167+
## Resolve parameter samples: use the in-memory bundle when passed,
168+
## otherwise fall back to loading samples.Rdata from disk.
169+
if (!is.null(samples)) {
170+
existing_data <- samples
171+
} else {
172+
samples.file <- file.path(settings$outdir, "samples.Rdata")
173+
if (file.exists(samples.file)) {
174+
existing_data <- new.env()
175+
load(samples.file, envir = existing_data) ## loads ensemble.samples, trait.samples, sa.samples, runs.samples, env.samples
185176
} else {
186-
# use pre-generated samples
187-
ensemble.samples <- existing_data$ensemble.samples
177+
PEcAn.logger::logger.error(samples.file, "not found, this file is required by the run.write.configs function")
178+
}
179+
}
180+
181+
trait.samples <- existing_data$trait.samples
182+
sa.samples <- existing_data$sa.samples
183+
184+
# build ensemble.samples only for ensemble runs
185+
# SA runs use sa.samples directly (quantile-based), not ensemble.samples
186+
if ("ensemble" %in% names(settings) &&
187+
!is.null(input_design) &&
188+
"param" %in% colnames(input_design)) {
189+
trait_sample_indices <- input_design[["param"]]
190+
ensemble.samples <- list()
191+
for (pft in names(trait.samples)) {
192+
pft_traits <- trait.samples[[pft]]
193+
ensemble.samples[[pft]] <- as.data.frame(
194+
lapply(
195+
names(pft_traits),
196+
function(trait) pft_traits[[trait]][trait_sample_indices]
197+
)
198+
)
199+
names(ensemble.samples[[pft]]) <- names(pft_traits)
188200
}
189201
} else {
190-
PEcAn.logger::logger.error(samples.file, "not found, this file is required by the run.write.configs function")
202+
# use pre-generated samples
203+
ensemble.samples <- existing_data$ensemble.samples
191204
}
192205

193206
## remove previous runs.txt

0 commit comments

Comments
 (0)