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)
| 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 |
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)
| 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. |
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 ...
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"
)
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
scoreandactualScorewith Buzz before drawing conclusions from them.
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)
| 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)
| source | missing_observation_id | missing_observer | missing_taxon | missing_community_taxon |
|---|---|---|---|---|
| iNaturalist | 0 | 0 | 1 | 14 |
| Firestore | 0 | 0 | 1 | 12 |
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)
| 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)
| metric | value |
|---|---|
| records | 92 |
| observed_taxa | 53 |
| accepted_taxa | 53 |
| observers | 7 |
| potential_score_from_rows | 870 |
| stored_score_from_rows | 1503 |
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)
| 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.
Our report shows a total of 50 species, whereas the app shows a total of 53 species.
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
This is a relatively simple one.
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.
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