1 Purpose

This report investigates why the headline statistics for BRPC Ovingdean July 2026 differ between the iNaturalist project, the BioBlitz Battle app and the generated event report.

displayed_stats <- tribble(
  ~source, ~records, ~species, ~verified_species, ~observers, ~potential_score, ~confirmed_score,
  "Generated report", 92, 50, 39, 16, 1368, 881,
  "iNaturalist project", 107, 45, NA, 10, NA, NA,
  "BioBlitz app", NA, 53, NA, 23, 1317, 848
)

displayed_stats |>
  kable(caption = "Headline figures displayed by each system") |>
  kable_styling(full_width = FALSE)
Headline figures displayed by each system
source records species verified_species observers potential_score confirmed_score
Generated report 92 50 39 16 1368 881
iNaturalist project 107 45 NA 10 NA NA
BioBlitz app NA 53 NA 23 1317 848

2 Metric definitions

metric_definitions <- tribble(
  ~metric, ~working_definition,
  "Records", "Distinct iNaturalist observations associated with the event.",
  "Species", "Distinct taxa counted as species by the relevant system.",
  "Verified species", "Distinct accepted community taxa among verified observations.",
  "Observers", "Distinct iNaturalist users represented in included observations.",
  "Potential score", "Score if all included observations were verified and accepted.",
  "Confirmed score", "Score currently awarded to verified and accepted observations."
)

metric_definitions |>
  kable(caption = "Working definitions") |>
  kable_styling(full_width = FALSE)
Working definitions
metric working_definition
Records Distinct iNaturalist observations associated with the event.
Species Distinct taxa counted as species by the relevant system.
Verified species Distinct accepted community taxa among verified observations.
Observers Distinct iNaturalist users represented in included observations.
Potential score Score if all included observations were verified and accepted.
Confirmed score Score currently awarded to verified and accepted observations.

3 1. Load source data

3.1 1.1 iNaturalist project

In house function that uses the inat api to download the data for a specific inat project.

# inat_raw <- download_inat_project(params$inat_project_id)
inat_raw <- get_inat_obs_project_v2("brpc-ovingdean-july-2026")
## Fetching page 1 ...
## Fetching page 2 ...

3.2 1.2 Firestore event data

Get relevant data from firestore.

project_id <- "rockpool-challenge"

library(googleAuthR)

json_key_file <- "rockpool-challenge-firebase-adminsdk-upy1z-c53064e8e9.json"

gar_auth_service(
  json_file = json_key_file,
  scope = "https://www.googleapis.com/auth/datastore"
)

library(httr2)
library(jsonlite)

access_token <- googleAuthR::gar_token()$auth_token$credentials$access_token

url <- paste0(
  "https://firestore.googleapis.com/v1/projects/",
  project_id,
  "/databases/(default)/documents/events"
)

resp <- request(url) |>
  req_auth_bearer_token(access_token) |>
  req_perform()

event_info <- load_event_document(
   project_id = params$firebase_project_id,
   event_slug = params$event_slug,
   parser = parse_event
 )

app_observations_raw <- load_event_collection(
  project_id = params$firebase_project_id,
  event_slug = params$event_slug,
  collection = "observations",
  parser = parse_observation
)


user_stats_raw <- load_event_collection(
  project_id = params$firebase_project_id,
  event_slug = params$event_slug,
  collection = "userStats"
)

team_stats_raw <- load_event_collection(
  project_id = params$firebase_project_id,
  event_slug = params$event_slug,
  collection = "teamStats"
)

taxonomy_stats_raw <- load_event_collection(
  project_id = params$firebase_project_id,
  event_slug = params$event_slug,
  collection = "taxonomyStats"
)

4 2. Standardise the data

Adapt the field names to allow matching.

inat_obs <- inat_raw |>
  transmute(
    observation_id = as.character(id),
    observer_login = as.character(user.login),
    observer_id = as.character(user.id),
    observed_taxon_id = as.character(taxon.id),
    community_taxon_id = as.character(community_taxon_id),
    taxon_rank = as.character(taxon.rank),
    quality_grade = as.character(quality_grade),
    observed_at = suppressWarnings(ymd_hms(observed_on, quiet = TRUE)),
    captive = as.logical(captive),
    accepted_taxon_id = if_else(
      !is.na(community_taxon_id) & community_taxon_id != "",
      community_taxon_id,
      observed_taxon_id
    ),
    is_verified = quality_grade == "research"
  ) |>
  distinct(observation_id, .keep_all = TRUE)
app_obs <- app_observations_raw |>
  transmute(
    observation_id = as.character(observation_id),
    observer_login = as.character(userId),
    team_id = as.character(teamId),
    observed_taxon_id = as.character(taxonId),
    community_taxon_id = as.character(communityTaxonId),
    stored_score = suppressWarnings(as.numeric(score)),
    potential_score = suppressWarnings(as.numeric(actualScore)),
    accepted_taxon_id = if_else(
      !is.na(community_taxon_id) & community_taxon_id != "",
      community_taxon_id,
      observed_taxon_id
    )
  ) |>
  distinct(observation_id, .keep_all = TRUE)

Need to confirm the meanings of score and actualScore with Buzz before drawing conclusions from them.

5 3. Basic checks

duplicate_summary <- tribble(
  ~source, ~rows, ~distinct_observations, ~duplicate_rows,
  "iNaturalist", nrow(inat_obs), n_distinct(inat_obs$observation_id),
  nrow(inat_obs) - n_distinct(inat_obs$observation_id),
  "Firestore", nrow(app_obs), n_distinct(app_obs$observation_id),
  nrow(app_obs) - n_distinct(app_obs$observation_id)
)

duplicate_summary |>
  kable(caption = "Row and duplicate checks") |>
  kable_styling(full_width = FALSE)
Row and duplicate checks
source rows distinct_observations duplicate_rows
iNaturalist 107 107 0
Firestore 92 92 0
missing_summary <- bind_rows(
  inat_obs |>
    summarise(
      source = "iNaturalist",
      missing_observation_id = sum(is.na(observation_id) | observation_id == ""),
      missing_observer = sum(is.na(observer_login) | observer_login == ""),
      missing_taxon = sum(is.na(observed_taxon_id) | observed_taxon_id == ""),
      missing_community_taxon = sum(is.na(community_taxon_id) | community_taxon_id == "")
    ),
  app_obs |>
    summarise(
      source = "Firestore",
      missing_observation_id = sum(is.na(observation_id) | observation_id == ""),
      missing_observer = sum(is.na(observer_login) | observer_login == ""),
      missing_taxon = sum(is.na(observed_taxon_id) | observed_taxon_id == ""),
      missing_community_taxon = sum(is.na(community_taxon_id) | community_taxon_id == "")
    )
)

missing_summary |>
  kable(caption = "Missing critical fields") |>
  kable_styling(full_width = FALSE)
Missing critical fields
source missing_observation_id missing_observer missing_taxon missing_community_taxon
iNaturalist 0 0 1 14
Firestore 0 0 1 12

6 4. Independently recalculate headline statistics

source("species leaves functions.R")

inat_summary <- inat_raw |>
  summarise(
    records = n_distinct(id),
    observed_taxa = length(inat_leaves(inat_raw)),
    community_taxa = length(com_leaves(inat_raw)),
    research_grade_records = sum(inat_raw$quality_grade == "research"),
    observers = n_distinct(user.login, na.rm = TRUE)
  )

inat_summary |>
  pivot_longer(everything(), names_to = "metric", values_to = "value") |>
  kable(caption = "Statistics recalculated from iNaturalist") |>
  kable_styling(full_width = FALSE)
Statistics recalculated from iNaturalist
metric value
records 107
observed_taxa 45
community_taxa 38
research_grade_records 81
observers 10
app_summary <- app_obs |>
  summarise(
    records = n_distinct(observation_id),
    observed_taxa = n_distinct(observed_taxon_id, na.rm = TRUE),
    accepted_taxa = n_distinct(accepted_taxon_id, na.rm = TRUE),
    observers = n_distinct(observer_login, na.rm = TRUE),
    potential_score_from_rows = sum(potential_score, na.rm = TRUE),
    stored_score_from_rows = sum(stored_score, na.rm = TRUE)
  )

app_summary |>
  pivot_longer(everything(), names_to = "metric", values_to = "value") |>
  kable(caption = "Statistics recalculated from Firestore observations") |>
  kable_styling(full_width = FALSE)
Statistics recalculated from Firestore observations
metric value
records 92
observed_taxa 53
accepted_taxa 53
observers 7
potential_score_from_rows 870
stored_score_from_rows 1503

7 5. Observation-level reconciliation

observation_membership <- full_join(
  inat_obs |>
    select(
      observation_id,
      inat_observer = observer_login,
      inat_observed_taxon = observed_taxon_id,
      inat_community_taxon = community_taxon_id,
      quality_grade,
      is_verified
    ) |>
    mutate(in_inat = TRUE),
  app_obs |>
    select(
      observation_id,
      app_observer = observer_login,
      app_observed_taxon = observed_taxon_id,
      app_community_taxon = community_taxon_id,
      team_id,
      stored_score,
      potential_score
    ) |>
    mutate(in_firestore = TRUE),
  by = "observation_id"
) |>
  mutate(
    in_inat = replace_na(in_inat, FALSE),
    in_firestore = replace_na(in_firestore, FALSE),
    membership = case_when(
      in_inat & in_firestore ~ "In both",
      in_inat & !in_firestore ~ "iNaturalist only",
      !in_inat & in_firestore ~ "Firestore only"
    )
  )
observation_membership |>
  count(membership, name = "observations") |>
  kable(caption = "Observation IDs present in each source") |>
  kable_styling(full_width = FALSE)
Observation IDs present in each source
membership observations
In both 92
iNaturalist only 15

So 15 records are only found in the iNat project. All the other records (92) are in both the iNat project and the app.

inat_only_recs <- observation_membership |>
  filter(membership == "iNaturalist only") 

inat_only_recs_users <- unique(inat_only_recs$inat_observer)

inat_only_recs_raw <- subset(inat_raw, id %in% inat_only_recs$observation_id)

unique(inat_only_recs_raw$user.id) %in% unlist(event_info$inatIds)
## [1] TRUE TRUE TRUE

There are three users who have collected the 15 inat only records. All three of them are registered for the event via the app but are not in the user stats table - it appears that they have not created or joined a team.

7.1 Species

Our report shows a total of 50 species, whereas the app shows a total of 53 species.

  • The report’s figure is based on the number of rows in the taxonomyStats collection.
  • The app’s figure appears to count the number of unique non-NA values in the taxonId field of the observations collection.

The iNaturalist project shows 45 species but we wouldn’t expect this to match now that we know there are records in the inat project that are not in the app. If we calculate the number of species from the app observations using inat’s leaf pruning method, we get a total of 45 species, which is what the app should show. The fact that this figure is the same as shown in the inat project is a coincidence due to there being no unique taxa in the 15 records that haven’t made it on to the app.

The ‘verified_species’ figure in the report is the number of species for the community taxa using the inat leaf pruning method. This is a total of 39 species. There is no equivilent figure reported by the app currently.

#report species count
nrow(taxonomy_stats_raw)
## [1] 50
#app species count
sum(!is.na(unique(app_observations_raw$taxonId)))
## [1] 53
#'true' species count
inat_raw_app_recs <- subset(inat_raw, !id %in% inat_only_recs$observation_id)
app_leaf_taxa <- inat_leaves(inat_raw_app_recs)
length(app_leaf_taxa)
## [1] 45
# community verified species
length(com_leaves(inat_raw_app_recs))
## [1] 39

7.2 Number of observers

This is a relatively simple one.

  • The report shows a figure of 16, which is the number of rows in the user stats collection.
  • The iNat project shows a figure of 10, which is the number of people who recorded data
  • The app shows a figure of 23 members, which is the number of people registered for the event.

We can fix this easily in the report by reporting the number of unique users in the observations table.

length(unique(app_observations_raw$userId))
## [1] 7

At the moment this figure is seven due the three members whose records are currently not included in the app.

8 Scores

iNaturalist doesn’t do the scoring bit, so this is a straight comparison between the report and the app.

The report currently uses the user stats collection for both the total potential score for the event and the total confirmed score for the event, summing the potentialScore and actualScore field respectively (1,368 and 881). This approach is definitely wrong as is will double count any taxa recorded by multiple people.

The corresponding figures shown by the app are 1317 and 848. These seem to be calculated by totaling the team scores for the event, which is also incorrect as many taxa will be duplicated across teams.

Al sent me files for the app’s scoring engine, which I ran through chatgpt and asked it to give me the corresponding code. One thing this exposed is that records with no taxon id (listed with the name ‘unknown’) are being assigned a potential score of 200. Whilst perhaps not technically incorrect it is extremely unlikely these records will score so highly and a potential score of 0 or NA would make more sense.

I have managed to produce code to replicate the app scoring engine, which I believe is doing so accurately as the resulting potential and confirmed team scores match those returned by the team stats collection for the event. This work discovered a potential minor issue with the potential scores. Initially these did not match and Chatgpt thinks this is because:

The reason is that Buzz’s engine does not necessarily score a potential record using its submitted taxonId. It retains and deduplicates using the submitted taxon, but when assigning points it tries the community taxon first, where one exists.

Potential scores should be based purely on taxon ids and community taxon ids are only relevant for confirmed scores. Not sure if this issue is of any real consequence in the app as the leagues only show confirmed scores, but maybe in the observations tab?

#report potential score
sum(team_stats_raw$potentialScore)
## [1] 1317
#report actual score
sum(team_stats_raw$actualScore)
## [1] 848
#app scores

#'true' scores
#potential
app_observations_raw_unique <- subset(app_observations_raw, !duplicated(taxonId))
app_observations_raw_unique <- subset(app_observations_raw_unique, taxonId %in% app_leaf_taxa)

sum(app_observations_raw_unique$score)
## [1] 898
#confirmed
sum(app_observations_raw_unique$actualScore, na.rm = T)
## [1] 574
#calculate overall event scores using the current R implementation of the scoring engine

source("buzz scoring engine.R")

obs <- app_observations_raw |>
   transmute(
     observation_id = as.character(observation_id),
    user_id = as.character(userId),
     team_id = na_if(as.character(teamId), ""),
     timestamp = timestamp,
     
     taxon_id = as.character(taxonId),
     taxon_rank = tolower(as.character(taxonRank)),
     taxon_ancestry = as.character(taxonAncestry),
    community_taxon_id = na_if(as.character(communityTaxonId), ""),
     community_taxon_rank = tolower(as.character(communityTaxonRank)),
     community_taxon_ancestry = as.character(communityTaxonAncestry)
   )

overall_event_result <- score_team(obs)

overall_event_scores <- tibble(
     potential_taxa = overall_event_result$potential_taxa,
     confirmed_taxa = overall_event_result$confirmed_taxa,
     potential_score = overall_event_result$potential_score,
confirmed_score = overall_event_result$confirmed_score
 )

overall_event_scores
## # A tibble: 1 × 4
##   potential_taxa confirmed_taxa potential_score confirmed_score
##            <int>          <int>           <dbl>           <dbl>
## 1             46             39            1071             630