Skip to contents

Depth-Stratified Fishing Effort from Global Fishing Watch

This article turns Global Fishing Watch (GFW) fishing-effort data from a flat 2D map into a 3D stack with one layer per depth. You can then overlap that stack with shark and ray ranges using [calc_volume_overlap()].

Three tools do the work:

  • gfwr fetches the effort data from the GFW API.
  • [gfw_effort_to_raster()] puts it on your study grid.
  • [as_envelope()] and [envelope_to_voxel()] extend it into 3D.

The depth at which each gear fishes is an assumption, not a measurement. This article therefore defines it as a lookup table you edit, instead of the package shipping one.

What GFW gives you, and what is missing

GFW (Kroodsma et al. 2018, Science) publishes apparent fishing effort as hours per grid cell, time period, flag state, and gear type. The product is 2D. A longline fishing at 80 m and a bottom trawl working the seafloor at 400 m both appear as the same kind of effort pixel. If a species uses only part of the water column, measuring its overlap against flat effort will over- or under-state the risk.

The workflow has three steps:

  1. Fetch effort for a region with gfwr::gfw_ais_fishing_hours(), grouped by gear type.
  2. Rasterise it onto your study grid with [gfw_effort_to_raster()], one layer per gear.
  3. Extend into 3D. Give each gear a depth window, then turn that window into depth layers with [as_envelope()] and [envelope_to_voxel()]. The result is a stack named effort_<geartype>_depth=<value>.

Before you start

You need three things:

The code chunks do not run when this article is built, so every output shown is an illustration rather than a live result.

# library(ocean3d)
library(terra)
library(here)
library(gfwr)

Step 1: Fetch apparent fishing effort

gfw_ais_fishing_hours() returns a long tibble with one row per cell, time bucket, and group level. For an EEZ, MPA, or RFMO region, first convert the region name into a numeric ID with gfwr::gfw_region_id(). The API needs the ID, not an ISO3 code.

# Resolve the Japan EEZ to its numeric region ID.
jpn_eez <- gfw_region_id(region = "JPN", region_source = "EEZ")$id

effort <- gfw_ais_fishing_hours(
  spatial_resolution  = "HIGH",        # 0.01 deg
  temporal_resolution = "YEARLY",      # one bucket per year in [start, end]
  start_date          = "2022-01-01",
  end_date            = "2022-12-31",
  region              = jpn_eez,
  region_source       = "EEZ",
  group_by            = "GEARTYPE"
)

head(effort)
#> # A tibble: 6 x 6
#>     Lat   Lon `Time Range` geartype           `Vessel IDs` `Apparent Fishing Hours`
#>   <dbl> <dbl>        <dbl> <chr>                     <dbl>                    <dbl>
#> 1  35.4 140.1         2022 trawlers                      1                     0.95
#> 2  34.6 138.9         2022 inconclusive                  1                     1.85
#> 3  35.1 139.7         2022 fishing                       1                     3.05
#> 4  33.2 137.4         2022 drifting_longlines            1                     2.38
#> ...

GFW returns gear labels in lower case, such as trawlers and drifting_longlines. Vessels the classifier cannot assign get the label inconclusive. The lookup below marks those as unknown, and step 3 skips them.

Step 2: Rasterise onto the study grid

Choose a target grid, then rasterise the effort tibble onto it. Use the same grid you use for species ranges and WOA extraction. The function returns one layer per level of layer_by.

# Use the bathymetry grid as the canonical study grid.
bathy <- load_gebco_bathymetry(
  here("/home/jay/Programming_Projects/Big_Data/gebco_2025_sub_ice_topo/GEBCO_2025_sub_ice.nc")
)

# Crop bathymetry to the effort footprint (with a small buffer) before
# any heavy raster ops. GEBCO is global at 15 arc-seconds; cropping first
# turns a multi-GB reproject/aggregate into a few-MB one.
effort_extent <- terra::ext(
  min(effort$Lon) - 0.5, max(effort$Lon) + 0.5,
  min(effort$Lat) - 0.5, max(effort$Lat) + 0.5
)
bathy_crop <- terra::crop(bathy, effort_extent)

# Canonical study grid: positive-down depth on the effort footprint,
# coarsened to ~0.04 deg for the worked example.
study_grid <- (terra::project(bathy_crop, "EPSG:4326") * -1) |>
  terra::aggregate(fact = 10)

effort_by_gear <- gfw_effort_to_raster(
  effort   = effort,
  grid     = study_grid,
  layer_by = "geartype",
  value    = "Apparent Fishing Hours",
  fun      = "sum"
)

names(effort_by_gear)
#> [1] "effort_drifting_longlines" "effort_set_gillnets"
#> [3] "effort_trawlers"           "effort_pots_and_traps"
#> ...

To group the data differently, for example one layer per flag state, re-fetch it with a different group_by. That is cheaper and more accurate than combining layers yourself.

Step 3: Extend into 3D

This step needs a depth_lookup table giving each gear class a depth band. The package does not ship one, and no package function reads one. Operating depths vary by region, fleet, and time period, so both the table and the code that uses it belong to your analysis. The values below are placeholders that show the mechanics. Replace them with values from the literature or from your own fishery.

The table has five columns:

  • geartype — the GFW gear label. Must match the geartype values in the effort tibble.
  • depth_min, depth_max — shallowest and deepest fishing depth in metres, measured downward from the surface. Use NA for benthic gears, whose band comes from bathymetry, and for unknown gears.
  • mode — "pelagic", "benthic", or "unknown".
  • benthic_buffer — for benthic gears, how many metres above the seafloor the gear fishes. NA otherwise.
# Placeholder values for demonstration only — substitute literature- or
# fishery-specific operating depths for real analyses.
depth_lookup <- data.frame(
  geartype = c(
    "drifting_longlines", "set_longlines", "set_gillnets",
    "trawlers", "dredge_fishing", "pole_and_line",
    "trollers", "purse_seines", "tuna_purse_seines",
    "other_purse_seines", "seiners", "squid_jigger",
    "pots_and_traps", "fixed_gear", "fishing", "inconclusive"
  ),
  depth_min      = c(  0, NA,   0, NA, NA,   0,   0,   0,   0,   0,   0,   0, NA, NA, NA, NA),
  depth_max      = c(400, NA, 140, NA, NA,  20, 150, 200, 250, 100, 200, 100, NA, NA, NA, NA),
  mode = c(
    "pelagic", "benthic", "pelagic",
    "benthic", "benthic", "pelagic",
    "pelagic", "pelagic", "pelagic",
    "pelagic", "pelagic", "pelagic",
    "benthic", "benthic", "unknown", "unknown"
  ),
  benthic_buffer = c( NA, 50,  NA, 50, 10,  NA,  NA,  NA,  NA,  NA,  NA,  NA, 50, 50, NA, NA),
  stringsAsFactors = FALSE
)

How the conversion works

Each row of the lookup describes an envelope: for every cell, the shallowest and deepest depth that gear fishes. [as_envelope()] builds the envelope, and [envelope_to_voxel()] spreads the cell’s fishing hours down it.

The two modes differ only in where the depth limits come from:

  • Pelagic gears fish a fixed band, so the limits are two numbers.
  • Benthic gears fish close to the seafloor, so the limits are rasters: [seafloor − buffer, seafloor].

Two arguments to [envelope_to_voxel()] carry assumptions you should know about:

  • profile = profile_equal splits each cell’s hours evenly across the depths its window reaches, so the total number of hours is preserved. Use [profile_flat()] to write the full value at every depth instead. That double-counts, and suits footprint maps only.
  • bounds = "midpoint" treats each depth level as the water halfway to its neighbours. This is the World Ocean Atlas convention, which the standard_depths below follow. A window that falls between two levels still lands in the level whose band contains it, so no hours are lost.
# Use WOA's 57 standard depths (0–1500 m) as the vertical grid.
standard_depths <- c(
  0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100,
  125, 150, 175, 200, 225, 250, 275, 300, 325, 350, 375, 400, 425, 450, 475, 500,
  550, 600, 650, 700, 750, 800, 850, 900, 950, 1000,
  1050, 1100, 1150, 1200, 1250, 1300, 1350, 1400, 1450, 1500
)

# `study_grid` already carries positive-down depth on the cropped
# footprint; clamp non-ocean cells to 0 to get the seafloor reference.
seafloor <- terra::clamp(study_grid, lower = 0)

Benthic gear: trawlers

Trawls fish close to the seafloor, so the window follows bathymetry instead of fixed numbers. Both limits are rasters, and every cell gets its own window.

trawl_effort <- effort_by_gear[["effort_trawlers"]]
trawl_buffer <- depth_lookup$benthic_buffer[depth_lookup$geartype == "trawlers"]
trawl_buffer
#> [1] 50

# Step 1 — the 2.5D envelope. A band `trawl_buffer` metres thick riding the
# seafloor, clamped at the surface so a cell shallower than the buffer does
# not produce a negative depth. `trawl_effort` is also the footprint: cells
# with no trawling are NA, so they get no window.
trawl_env <- as_envelope(
  trawl_effort,
  depth_min = terra::clamp(seafloor - trawl_buffer, lower = 0),
  depth_max = seafloor
)

names(trawl_env)
#> [1] "depth_min" "depth_max"

# Step 2 — the 3D voxel. `values` is what gets spread down the window, and
# `profile_equal` splits each cell's hours evenly across the depths its
# window reaches, so the stack conserves total effort-hours.
trawlers_3d <- envelope_to_voxel(
  trawl_env,
  depths  = standard_depths,
  values  = trawl_effort,
  profile = profile_equal,
  bounds  = "midpoint",
  varname = "effort_trawlers"
)

names(trawlers_3d)[1:5]
#> [1] "effort_trawlers_depth=0"
#> [2] "effort_trawlers_depth=5"
#> [3] "effort_trawlers_depth=10"
#> [4] "effort_trawlers_depth=15"
#> [5] "effort_trawlers_depth=20"

# Nothing created, nothing lost.
sum(terra::global(trawlers_3d, "sum", na.rm = TRUE)$sum, na.rm = TRUE)
#> [1] <hours>
terra::global(trawl_effort, "sum", na.rm = TRUE)$sum
#> [1] <the same number>

The standard depths are 5 m apart near the surface, but 25 to 50 m apart below 100 m. The same 50 m band therefore resolves differently with depth: a shelf cell at 80 m spreads its hours over about a dozen layers, while a slope cell at 1200 m spreads them over two or three. A band that contains no standard depth at all still lands in the level whose band holds it, so its hours are kept.

Pelagic gear: purse seines

A purse seine fishes a fixed band near the surface. The limits are the same everywhere, so they come straight from the lookup as two numbers. The rest of the code is identical to the trawler example: both modes use the same pair of functions.

seine_effort <- effort_by_gear[["effort_purse_seines"]]
seine_band   <- depth_lookup[depth_lookup$geartype == "purse_seines", ]
seine_band[, c("depth_min", "depth_max")]
#>   depth_min depth_max
#> 8         0       200

# Step 1 — the 2.5D envelope. Constant limits, so `as_envelope()` broadcasts
# them across the footprint instead of reading a raster per cell.
seine_env <- as_envelope(
  seine_effort,
  depth_min = seine_band$depth_min,
  depth_max = seine_band$depth_max
)

# Step 2 — the 3D voxel. Identical to the trawler call; only the envelope
# and the variable name differ.
purse_seines_3d <- envelope_to_voxel(
  seine_env,
  depths  = standard_depths,
  values  = seine_effort,
  profile = profile_equal,
  bounds  = "midpoint",
  varname = "effort_purse_seines"
)

# A 0–200 m band reaches the first 25 of the 57 standard depths, so every
# fished cell spreads its hours over 25 layers at 1/25 each.
sum(!is.na(terra::values(purse_seines_3d)[1, ]))
#> [1] 25

The two modes are kept in separate stacks because they behave so differently. A benthic cell fills a handful of layers; a pelagic cell fills twenty-five. That matters for the 3D scene below.

Scaling to the whole fleet

The two blocks above are the whole pattern: pick the window, build the envelope, convert it. For a full analysis, repeat them for every row of depth_lookup and combine the results with c().

Two practical points:

  • Work one gear at a time. Memory then scales with one gear’s stack rather than the whole fleet’s. Write each stack to a tempfile with terra::writeRaster() before combining them.
  • A combined stack is no longer a voxel. Each single-gear stack is a [SpatVoxel-class]: one variable, one layer per depth, ordered shallow to deep. Combining gears breaks that order, but c() keeps the class label anyway, so you get an object that claims to be a voxel and is not. The writeRaster() step avoids the problem, because it returns a plain SpatRaster. Keep the single-gear stacks if you need the voxel class for [calc_volume_overlap()] or [voxel_to_envelope()].

Gears with mode = "unknown" have no depth information, so skip them. Placing their effort at the surface would invent a result rather than estimate one.

Visualising the benthic stack

A 3D scene is the clearest way to read trawlers_3d. The cubes sit on the seafloor, so you can see whether effort concentrates on the shelf or in deeper canyons, and the bathymetry surface gives each cube a reference. We use plotly to keep the scene interactive in the rendered article.

Only benthic effort is drawn. purse_seines_3d has twenty-five layers under every fished cell, and that many cubes exceeds what plotly can serialise.

library(plotly)
library(tidyr)
library(dplyr)
library(stringr)

# `trawlers_3d` comes from the benthic chunk above. Only the benthic stack
# is rendered; the helpers below are written to take any stack following the
# `{variable}_depth=<value>` convention.

# Vertical extent of each depth band: half-way to neighbouring standard
# depths (so adjacent cubes meet without gaps or overlap).
sd_sorted <- sort(unique(standard_depths))
n_sd      <- length(sd_sorted)
mids      <- (sd_sorted[-n_sd] + sd_sorted[-1]) / 2
bands <- data.frame(
  depth  = sd_sorted,
  z_low  = c(max(sd_sorted[1] - (sd_sorted[2] - sd_sorted[1]) / 2, 0), mids),
  z_high = c(mids, sd_sorted[n_sd] +
                   (sd_sorted[n_sd] - sd_sorted[n_sd - 1]) / 2)
)

# Concatenate one mesh3d trace covering all cubes. Each cube has 8 vertices
# and 12 triangles; vertex indices in plotly's mesh3d are 0-indexed.
make_cube_mesh <- function(voxels, dx, dy) {
  n <- nrow(voxels)
  off <- as.matrix(expand.grid(sx = c(-1, 1), sy = c(-1, 1), sz = c(0, 1)))
  rows <- voxels[rep(seq_len(n), each = 8), ]
  o    <- off[rep(seq_len(8), times = n), ]

  vx <- rows$x + o[, "sx"] * dx
  vy <- rows$y + o[, "sy"] * dy
  vz <- ifelse(o[, "sz"] == 0, rows$z_low, rows$z_high)

  tris <- rbind(
    c(0, 1, 2), c(1, 3, 2),  # shallow face
    c(4, 5, 6), c(5, 7, 6),  # deep face
    c(0, 4, 1), c(1, 4, 5),  # south face
    c(2, 3, 6), c(3, 7, 6),  # north face
    c(0, 2, 4), c(2, 6, 4),  # west face
    c(1, 5, 3), c(3, 5, 7)   # east face
  )
  base_v <- rep(seq(0L, by = 8L, length.out = n), each = 12)
  list(
    x = vx, y = vy, z = vz,
    i = rep(tris[, 1], n) + base_v,
    j = rep(tris[, 2], n) + base_v,
    k = rep(tris[, 3], n) + base_v,
    intensity = rows$effort
  )
}

# Turn a multi-layer effort stack into mesh3d cubes by collapsing across
# gears at each (x, y, depth) and joining per-depth z-band extents.
stack_to_cubes <- function(stack, dx, dy, bands) {
  voxels <- terra::as.data.frame(stack, xy = TRUE, na.rm = FALSE) |>
    pivot_longer(cols = -c(x, y), names_to = "layer", values_to = "effort") |>
    filter(!is.na(effort) & effort > 0) |>
    mutate(depth = as.numeric(str_extract(layer, "(?<=depth=)-?[0-9.]+"))) |>
    group_by(x, y, depth) |>
    summarise(effort = sum(effort), .groups = "drop") |>
    left_join(bands, by = "depth")
  make_cube_mesh(voxels, dx = dx, dy = dy)
}

# Only benthic effort is rendered. Pelagic gears fill dozens of depths per
# cell, so the cube count exceeds plotly's JSON serializer (R's 2 GB string
# limit) even after thinning by threshold.
res           <- terra::res(study_grid)
benthic_cubes <- stack_to_cubes(trawlers_3d, res[1] / 2, res[2] / 2, bands)

# Log-scale colour mapping. Plotly's mesh3d colourscale is linear in
# `intensity`, so we feed it log10(effort) directly, then relabel the
# colourbar ticks back to the original-scale values (1, 10, 100, ...).
benthic_log_intensity <- log10(benthic_cubes$intensity)
log_breaks <- 10 ^ seq(floor(min(benthic_log_intensity)),
                       ceiling(max(benthic_log_intensity)))

# Bathymetry surface and a flat sea surface at z = 0. Plotly expects the
# i-th row of z to align with y[i], so flip both together south-to-north.
bathy_x   <- terra::xFromCol(seafloor, seq_len(terra::ncol(seafloor)))
bathy_y   <- terra::yFromRow(seafloor, seq_len(terra::nrow(seafloor)))
bathy_mat <- terra::as.matrix(seafloor, wide = TRUE)
bathy_mat <- bathy_mat[nrow(bathy_mat):1, ]
bathy_y   <- rev(bathy_y)
sea_z     <- matrix(0, nrow = length(bathy_y), ncol = length(bathy_x))


# Coastline: contour the *native-resolution* `bathy_crop` (positive
# elevation on land, negative below sea level) at 0 — the actual coast.
# Using `seafloor` instead loses the coast geometry: aggregation by
# fact=10 flattens 10×10 GEBCO cells, and clamp(lower = 0) snaps
# negative-elevation cells onto the same plane as sea-level cells, so
# the contour zigzags through the coarsened grid rather than tracing
# real shorelines. Project onto the same CRS as the plot if needed.
coast_lines <- terra::as.contour(bathy_crop, levels = 0)
if (!terra::same.crs(coast_lines, study_grid)) {
  coast_lines <- terra::project(coast_lines, terra::crs(study_grid))
}
# Flatten each line segment into one (x, y) sequence and separate
# segments with NAs so plotly's scatter3d breaks the line at NA,
# preserving disconnected coastline pieces (islands, archipelagos).
coast_xy    <- sf::st_coordinates(sf::st_as_sf(coast_lines))
coast_split <- split(seq_len(nrow(coast_xy)),
                     paste(coast_xy[, "L1"], coast_xy[, "L2"]))
coast_x <- unlist(lapply(coast_split, function(idx) c(coast_xy[idx, "X"], NA)))
coast_y <- unlist(lapply(coast_split, function(idx) c(coast_xy[idx, "Y"], NA)))
coast_z <- ifelse(is.na(coast_x), NA_real_, 0)

# Namespace-qualify every plotly call: `terra` (loaded earlier in this
# vignette) exports a `layout` generic, and `graphics::layout` is on the
# search path too. Either can mask `plotly::layout` and trigger
# "unused argument (scene = ...)".
build_benthic_scene <- function(camera = NULL) {
  scene <- list(
    xaxis = list(title = "Longitude"),
    yaxis = list(title = "Latitude"),
    zaxis = list(title = "Depth (m)", autorange = "reversed"),
    # Compress the z-axis so depth doesn't dominate the scene. Tune the
    # `z` value down for less vertical stretch, up for more.
    aspectmode  = "manual",
    aspectratio = list(x = 1, y = 1, z = 0.05)
  )
  if (!is.null(camera)) scene$camera <- camera

  plotly::plot_ly() |>
    plotly::add_surface(
      x = bathy_x, y = bathy_y, z = bathy_mat,
      # Single surface keeps the coast geometry continuous — splitting
      # land/ocean into two NA-masked surfaces drops faces that bridge
      # the boundary, leaving ragged edges. Plotly interpolates colours
      # across faces, so the green→tan transition smears across the
      # 1-cell-wide coastal band; the coastline scatter trace below
      # overlays a sharp dark line that visually anchors the boundary.
      colorscale = list(
        c(0,    "rgb(140, 170, 110)"),  # land (z = 0): muted green
        c(1e-4, "rgb(180, 170, 150)"),  # shallow ocean: tan
        c(1,    "rgb( 60,  45,  30)")   # deep ocean: dark brown
      ),
      cmin = 0,
      cmax = max(bathy_mat, na.rm = TRUE),
      showscale = FALSE, opacity = 1, name = "Seafloor"
    ) |>
    plotly::add_trace(
      type = "scatter3d", mode = "lines",
      x = coast_x, y = coast_y, z = coast_z,
      line = list(color = "rgb(30, 25, 20)", width = 3),
      hoverinfo = "skip", showlegend = FALSE, name = "Coastline"
    ) |>
    plotly::add_trace(
      type = "mesh3d",
      x = benthic_cubes$x, y = benthic_cubes$y, z = benthic_cubes$z,
      i = benthic_cubes$i, j = benthic_cubes$j, k = benthic_cubes$k,
      intensity = benthic_log_intensity,
      colorscale = "Viridis", opacity = 0.5, flatshading = TRUE,
      colorbar = list(
        title    = "Effort\n(hours)",
        tickvals = log10(log_breaks),
        ticktext = format(log_breaks, big.mark = ",", scientific = FALSE,
                          trim = TRUE)
      ),
      name = "Benthic effort"
    ) |>
    plotly::layout(scene = scene)
}

build_benthic_scene()

Figure 1. Depth-stratified benthic fishing effort in the Japanese EEZ (2022), rendered as an interactive 3D scene. Each cube is one study-grid cell spanning one World Ocean Atlas standard depth band, coloured by apparent fishing effort in hours — summed across benthic gear types — on a log₁₀ scale. The shaded surface is GEBCO bathymetry, from muted green land through tan shelf to dark brown deep water, and the dark line traces the coastline. The depth axis is reversed so the sea surface sits at the top and the seafloor at the bottom.

Overlapping with a species range

trawlers_3d and purse_seines_3d follow the {variable}_depth=<value> naming convention, so they work with the rest of the package exactly as WOA rasters do:

# Example: silky shark (epipelagic, and heavily taken as bycatch in
# tuna purse seines around FADs).
# The IUCN shark/ray/chimaera shapefile carries one row per species range
# polygon. Filter at read time with SQL so the in-memory object is small.
iucn_shp <- "/home/jay/Programming_Projects/Big_Data/SHARKS_RAYS_CHIMAERAS/SHARKS_RAYS_CHIMAERAS.shp"

sp_range <- sf::st_read(
  iucn_shp,
  query = "SELECT * FROM SHARKS_RAYS_CHIMAERAS WHERE sci_name = 'Carcharhinus falciformis'",
  quiet = TRUE
)

# Depth limits are placeholder values for demonstration; in practice pull
# them per-species via fetch_species_assessments() / fill_missing_depths()
# from the IUCN Red List API.
range_rast <- vect_to_envelope(
  polygon   = sp_range,
  template  = study_grid,
  depth_min = 0,
  # The seafloor is just another depth_max constraint: per cell the shallowest
  # of the two wins, so the range never extends below the bed.
  depth_max = list(500, seafloor)
)

# Mask the 3D effort stack to cells + depths inside the species' per-cell
# depth window. mask() is depth-aware for an envelope: it places the range on
# the effort stack's own `effort_<gear>_depth=<value>` levels and keeps each
# layer's effort only where the species is present at that depth.
# `purse_seines_3d` is already one gear, so it goes in whole; a multi-gear
# stack would be subset by layer name first.
exposed_effort <- mask(purse_seines_3d, range_rast)

# Total purse-seine effort-hours overlapping the silky shark's 3D range.
total_exposed <- sum(
  terra::global(exposed_effort, "sum", na.rm = TRUE)$sum,
  na.rm = TRUE
)
total_exposed
#> [1] <hours>

# Per-depth exposure profile (effort-hours summed across cells, per layer).
per_depth <- terra::global(exposed_effort, "sum", na.rm = TRUE)
per_depth$depth <- depths(rownames(per_depth))
per_depth[!is.na(per_depth$sum) & per_depth$sum > 0,
          c("depth", "sum")]

Caveats

There are two groups: limits of the GFW AIS effort product itself, and limits of the depth step added here. The first group is drawn from the GFW data documentation.

Inherited from GFW AIS effort

  • AIS coverage favours large, offshore vessels. AIS is mainly required on vessels over 24 m working offshore, so small-scale and artisanal fleets are largely missing. SAR dark-vessel detections (Paolo et al. 2024) cover that gap.
  • Class A and Class B reception differ. Smaller vessels carry Class B units, which transmit at lower power. Satellites detect them less reliably, so smaller-vessel effort is under-represented even within the AIS fleet.
  • AIS can be disabled or spoofed. Deliberate gaps and falsified identities produce missing or misattributed positions.
  • Signal interference in busy areas. In high-traffic regions overlapping AIS transmissions degrade satellite detection, reducing apparent effort.
  • “Apparent” fishing is inferred from behaviour. The classifier flags movement patterns that look like searching, setting, soaking, and hauling. It does not confirm that gear went in the water. Expect false positives where vessels slow down or turn for other reasons.
  • Vessel-type and gear misclassification. Registry data is inconsistent; MMSI recycling can mix two vessels’ tracks; vessels that switch gears are inherently hard to classify. Treat per-gear effort as a best-available label, not a measurement.
  • Comparisons over time are confounded by AIS uptake. Year-on-year changes in apparent effort partly reflect changes in AIS adoption, satellite coverage, and shore-station access, not only changes in real fishing.

Added by this package’s depth-stratification

  • The depth lookup is the analyst’s responsibility. The package does not ship a default depth_lookup. GFW does not publish set depth, so any per-gear band is an assumption — and the right assumption depends on the region, fleet, and time period being analysed.
  • GFW does not always separate pelagic and benthic gear variants. Midwater and bottom trawls are the clearest case. In 2D the difference is invisible, but in 3D the two gears occupy completely different parts of the water column. Whatever you assign in depth_lookup carries that uncertainty.
  • [profile_equal()] preserves total effort-hours but assumes gear distributes evenly across the depths its window reaches; [profile_flat()] double counts and is only appropriate for footprint maps.
  • Depth windows are widened to whole levels. A 10 m benthic band can be assigned to a level tens of metres thick. This keeps the hours in the stack, but it means the vertical precision of the result is the spacing of standard_depths, not the precision of depth_lookup.

Function summary

Function Purpose
gfwr::gfw_ais_fishing_hours() Fetch GFW apparent fishing effort for a region, aggregated server-side by group_by
[gfw_effort_to_raster()] Rasterise the gfwr tibble onto the study grid, one layer per group level
[as_envelope()] Turn a gear’s effort footprint plus its depth limits into a 2.5D envelope
[envelope_to_voxel()] Spread each cell’s effort down the standard depths its window reaches
[profile_equal()] Allocation that splits a cell’s hours evenly, conserving the total