Skip to content

Commit c5a0585

Browse files
Merge pull request #24 from NewGraphEnvironment/7-stac-s3
Add STAC catalog generation and S3 push (#7, #8)
2 parents 8d930d8 + 012a57f commit c5a0585

11 files changed

Lines changed: 407 additions & 6 deletions

File tree

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export(cd_derive)
1414
export(cd_extract)
1515
export(cd_fetch)
1616
export(cd_periods)
17+
export(cd_s3_push)
18+
export(cd_stac_catalog)
1719
export(cd_summary)
1820
export(cd_trend)
1921
export(cd_variables)

R/cd_catalog.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,5 @@ cd_catalog <- function(catalog = cd_catalog_default()) {
5151
#'
5252
#' @export
5353
cd_catalog_default <- function() {
54-
getOption("cd.catalog_url", default = "https://nge-bc-ce.s3.us-west-2.amazonaws.com/cd/catalog.json")
54+
getOption("cd.catalog_url", default = "https://stac-era5-land.s3.us-west-2.amazonaws.com/catalog.json")
5555
}

R/cd_s3_push.R

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#' Push files to S3
2+
#'
3+
#' Syncs a local directory to an S3 bucket using `aws s3 sync`.
4+
#' Only uploads new or changed files (`--size-only`). Requires
5+
#' the AWS CLI to be installed and configured.
6+
#'
7+
#' @param local_dir Character. Local directory to sync.
8+
#' @param bucket Character. S3 bucket name. Default `"stac-era5-land"`.
9+
#' @param prefix Character. S3 key prefix (subdirectory in bucket).
10+
#' Default `""` (bucket root).
11+
#' @param dry_run Logical. If `TRUE`, shows what would be uploaded
12+
#' without actually uploading. Default `FALSE`.
13+
#'
14+
#' @return The exit code from `aws s3 sync` (invisibly). Zero on success.
15+
#'
16+
#' @examples
17+
#' \dontrun{
18+
#' # Preview what would be uploaded
19+
#' cd_s3_push("data/cogs", dry_run = TRUE)
20+
#'
21+
#' # Upload for real
22+
#' cd_s3_push("data/cogs")
23+
#'
24+
#' # Upload to a subdirectory in the bucket
25+
#' cd_s3_push("data/cogs", prefix = "v1")
26+
#' }
27+
#'
28+
#' @export
29+
cd_s3_push <- function(local_dir,
30+
bucket = "stac-era5-land",
31+
prefix = "",
32+
dry_run = FALSE) {
33+
if (!dir.exists(local_dir)) {
34+
rlang::abort(paste("Directory not found:", local_dir))
35+
}
36+
37+
s3_target <- if (nchar(prefix) > 0) {
38+
paste0("s3://", bucket, "/", prefix)
39+
} else {
40+
paste0("s3://", bucket)
41+
}
42+
43+
cmd <- sprintf(
44+
"aws s3 sync %s %s --exclude '.*' --size-only%s",
45+
shQuote(local_dir),
46+
shQuote(s3_target),
47+
if (dry_run) " --dryrun" else ""
48+
)
49+
50+
message("Running: ", cmd)
51+
exit_code <- system(cmd)
52+
53+
if (exit_code != 0 && !dry_run) {
54+
rlang::abort(paste("S3 sync failed with exit code", exit_code))
55+
}
56+
57+
invisible(exit_code)
58+
}

R/cd_stac_catalog.R

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#' Generate a static STAC catalog from COGs
2+
#'
3+
#' Scans a directory for Cloud-Optimized GeoTIFFs and builds a
4+
#' STAC catalog JSON file. Each COG becomes one item with
5+
#' `cd:variable` and `cd:period` properties parsed from the filename.
6+
#' The resulting catalog is compatible with [cd_catalog()].
7+
#'
8+
#' @param cog_dir Character. Directory containing COG files (.tif).
9+
#' @param output_path Character. Path to write the catalog JSON.
10+
#' Default `"catalog.json"`.
11+
#' @param catalog_id Character. STAC catalog ID.
12+
#' Default `"era5-land"`.
13+
#' @param title Character. Human-readable catalog title.
14+
#' @param description Character. Optional catalog description.
15+
#' @param base_url Character. Base URL where COGs will be served.
16+
#' Asset hrefs are built as `{base_url}/{filename}`.
17+
#'
18+
#' @return The output path (invisibly).
19+
#'
20+
#' @examples
21+
#' \dontrun{
22+
#' cd_stac_catalog(
23+
#' "data/cogs",
24+
#' output_path = "data/catalog.json",
25+
#' base_url = "https://stac-era5-land.s3.us-west-2.amazonaws.com"
26+
#' )
27+
#' }
28+
#'
29+
#' @export
30+
cd_stac_catalog <- function(cog_dir,
31+
output_path = "catalog.json",
32+
catalog_id = "era5-land",
33+
title = "ERA5-Land Climate Data",
34+
description = NULL,
35+
base_url = "https://stac-era5-land.s3.us-west-2.amazonaws.com") {
36+
37+
tif_files <- list.files(cog_dir, pattern = "\\.tif$", full.names = TRUE)
38+
if (length(tif_files) == 0) {
39+
rlang::abort(paste("No .tif files found in", cog_dir))
40+
}
41+
42+
items <- lapply(tif_files, function(f) {
43+
cd_stac_item(f, base_url)
44+
})
45+
46+
catalog <- list(
47+
type = "Catalog",
48+
id = catalog_id,
49+
stac_version = "1.0.0",
50+
description = description %||% paste(title, "- static STAC catalog"),
51+
links = list(
52+
list(rel = "root", href = paste0("./", basename(output_path)),
53+
type = "application/json")
54+
),
55+
items = items
56+
)
57+
58+
dir.create(dirname(output_path), recursive = TRUE, showWarnings = FALSE)
59+
jsonlite::write_json(catalog, output_path, pretty = TRUE, auto_unbox = TRUE)
60+
message("Wrote STAC catalog: ", output_path, " (", length(items), " items)")
61+
invisible(output_path)
62+
}
63+
64+
#' Build a STAC item from a COG file
65+
#'
66+
#' Parses variable and period from the filename, extracts spatial
67+
#' metadata from the raster.
68+
#'
69+
#' @param cog_path Path to a COG file.
70+
#' @param base_url Base URL for asset hrefs.
71+
#' @return A list representing a STAC Feature item.
72+
#' @noRd
73+
cd_stac_item <- function(cog_path, base_url) {
74+
fname <- basename(cog_path)
75+
name_parts <- tools::file_path_sans_ext(fname)
76+
77+
# Parse variable and period from filename
78+
# Expected patterns: "tmean_annual.tif", "vpd_2024.tif",
79+
# "example_climate.tif", etc.
80+
known_vars <- cd_variables()$variable
81+
known_periods <- cd_periods(include_monthly = TRUE)
82+
83+
var_match <- known_vars[vapply(known_vars, function(v) grepl(v, name_parts), logical(1))]
84+
period_match <- known_periods[vapply(known_periods, function(p) grepl(p, name_parts), logical(1))]
85+
86+
variable <- if (length(var_match) > 0) var_match[1] else name_parts
87+
period <- if (length(period_match) > 0) period_match[1] else "unknown"
88+
89+
# Extract spatial metadata
90+
r <- terra::rast(cog_path)
91+
e <- as.vector(terra::ext(r))
92+
n_bands <- terra::nlyr(r)
93+
band_names <- names(r)
94+
95+
# Parse years from band names if numeric
96+
years <- suppressWarnings(as.integer(band_names))
97+
years <- years[!is.na(years)]
98+
99+
item_id <- paste(variable, period, sep = "-")
100+
101+
list(
102+
type = "Feature",
103+
stac_version = "1.0.0",
104+
id = item_id,
105+
geometry = NA,
106+
bbox = e[c(1, 3, 2, 4)],
107+
properties = list(
108+
`cd:variable` = variable,
109+
`cd:period` = period,
110+
datetime = NA,
111+
start_datetime = if (length(years) > 0) paste0(min(years), "-01-01T00:00:00Z") else NULL,
112+
end_datetime = if (length(years) > 0) paste0(max(years), "-12-31T23:59:59Z") else NULL
113+
),
114+
links = list(),
115+
assets = list(
116+
data = list(
117+
href = paste0(base_url, "/", fname),
118+
type = "image/tiff; application=geotiff; profile=cloud-optimized",
119+
title = paste(variable, period)
120+
)
121+
)
122+
)
123+
}

inst/extdata/example_catalog.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,18 @@
2020
"type": "Feature",
2121
"stac_version": "1.0.0",
2222
"id": "tmean-annual",
23-
"geometry": {},
24-
"bbox": [-126.75, 54.1, -125.75, 54.7],
23+
"bbox": [
24+
-126.75,
25+
54.1,
26+
-125.75,
27+
54.7
28+
],
2529
"properties": {
2630
"cd:variable": "tmean",
2731
"cd:period": "annual",
28-
"datetime": {},
2932
"start_datetime": "1951-01-01T00:00:00Z",
30-
"end_datetime": "1960-12-31T23:59:59Z"
33+
"end_datetime": "1960-12-31T23:59:59Z",
34+
"datetime": null
3135
},
3236
"links": [],
3337
"assets": {
@@ -36,7 +40,8 @@
3640
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
3741
"title": "Mean temperature annual values"
3842
}
39-
}
43+
},
44+
"geometry": null
4045
}
4146
]
4247
}

man/cd_s3_push.Rd

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

man/cd_stac_catalog.Rd

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

planning/active/progress.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Progress
2+
3+
## Status: Complete
4+
5+
### cd_stac_catalog() — Issue #7
6+
- [x] Implementation
7+
- [x] Tests
8+
- [x] Docs
9+
- [x] `/code-check` — fixed geometry/datetime null serialization (NA for JSON null)
10+
- [x] Committed
11+
12+
### cd_s3_push() — Issue #8
13+
- [x] Implementation
14+
- [x] Tests
15+
- [x] Docs
16+
- [x] `/code-check` — fixed command injection via shQuote on s3_target
17+
- [x] Committed
18+
19+
### Final
20+
- [x] `devtools::test()` all pass (142/142)
21+
- [x] `lintr::lint_package()` clean (false positives only)
22+
- [ ] End-to-end with real S3 (after merge)
23+
- [ ] PR created

planning/active/task_plan.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# STAC Catalog + S3 Push
2+
3+
## Context
4+
Final producer functions. Generate static STAC catalog JSON from COGs, push to `stac-era5-land` S3 bucket. Completes the producer pipeline: fetch → derive → cog_write → stac_catalog → s3_push.
5+
6+
## Issues
7+
- #7 `cd_stac_catalog()` — STAC JSON generation
8+
- #8 `cd_s3_push()` — S3 upload
9+
10+
## Tasks
11+
- [ ] Fix `cd_catalog_default()` URL to real bucket
12+
- [ ] Implement `cd_stac_catalog()` in `R/cd_stac_catalog.R`
13+
- [ ] Implement `cd_s3_push()` in `R/cd_s3_push.R`
14+
- [ ] Write tests for each function
15+
- [ ] End-to-end: generate catalog → read with cd_catalog()
16+
- [ ] `devtools::test()` — all pass
17+
- [ ] `lintr::lint_package()` — clean
18+
- [ ] `/code-check` before each commit

tests/testthat/test-cd_s3_push.R

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
test_that("cd_s3_push is a function", {
2+
expect_true(is.function(cd_s3_push))
3+
})
4+
5+
test_that("cd_s3_push errors on missing directory", {
6+
expect_error(
7+
cd_s3_push("/nonexistent/path/abc123"),
8+
"Directory not found"
9+
)
10+
})
11+
12+
test_that("cd_s3_push default bucket is stac-era5-land", {
13+
# Verify the default by inspecting the function formals
14+
defaults <- formals(cd_s3_push)
15+
expect_equal(defaults$bucket, "stac-era5-land")
16+
})

0 commit comments

Comments
 (0)