Inyo County, California

totals and means
LADWP data
OVGA
time series
LADWP water-year monthly volumetric flow data transfer. Code automates extracting volumetric flow data from ZRXP ASCII data exchange format, transforming and merging updates with the active database, and exporting a csv file formatted for the OVGA data management system for groundwater production and surface water gage totals.
Author

Inyo County Water Department

Published

January 31, 2026

Modified

January 31, 2026

code
source(here('code','R','functions.R'))

Abstract

This report documents water-year processing of LADWP monthly flow and production data, including data ingestion from ZRXP files, QA/QC checks, integration with historical totals, and preparation of OVGA-formatted deliverables.

Outputs include a refreshed totals-and-means table, OVGA upload files for flow and production, and QA/QC diagnostics for linked wells, wellfields, and stream gages. The processing steps below are organized to mirror the annual update workflow, from raw inputs to export-ready files.

Annual update instructions: See ANNUAL_UPDATE_GUIDE.md for comprehensive step-by-step instructions on updating this workflow for the next water year.

Data sources and preprocessing

WY2025 inputs are delivered as ZRXP ASCII files. The parser extracts the staid from the #TSPATH headers and reads monthly volume values. We then separate flow records (numeric staids) from production records (alpha staids), and join metadata needed for OVGA exports.

read data
file_path_flow <-here('data','hydro','2024-25 Water Year Transfer Packet for ICWD','VolumeMonthAF_2024-25.dat')

sum_stations_path <- here('data','hydro','2024-25 Water Year Transfer Packet for ICWD','2025 - Inyo County Summary Stations.xlsx')
sum_stations <- if (file.exists(sum_stations_path)) {
  readxl::read_excel(sum_stations_path, sheet = "Data", skip = 2) %>%
    rename(date = 1) %>%
    mutate(date = as.Date(date))
} else {
  tibble(date = as.Date(character()))
}

staidlist <- read_csv(here('data','hydro','staid_current.csv')) %>%
  mutate(staid = as.character(staid))

ovga_points <- read_csv(here("data","hydro","Owens_Monitoring_Points.csv"))

production.meta <- read_csv(here('data','hydro','Production.csv')) %>% 
  select(mon_pt_name, method) %>% unique()
  
monthlies <- read_csv(here('data','hydro','totals_means_thru_sep_2024.csv'))

flow.meta <- read_csv(here('data','hydro','Flows.csv')) %>%  
  select(mon_pt_name, LADWP_ID) %>% 
  unique() %>% 
  mutate(staid = as.character(LADWP_ID)) %>% 
  select(-LADWP_ID)
parse .dat ascii
parse_volume_dat <- function(file_path) {
  dat_content <- readLines(file_path)

  data_list <- list()
  is_data_line <- function(line) {
    grepl("\\d+ \\d+\\.\\d+", line)
  }

  staid <- NA_character_
  for (line in dat_content) {
    if (startsWith(line, "#TSPATH")) {
      staid <- sub(".*/([^/]+)/VOLUME/.*", "\\1", line)
    }
    if (!startsWith(line, "#") && is_data_line(line) && !is.na(staid)) {
      data_list <- c(data_list, list(data.frame(staid, dateread = line)))
    }
  }

  dplyr::bind_rows(data_list)
}

ann.update <- parse_volume_dat(file_path_flow)
separate date read column
#separate the data column and assign numeric class to value
ann.update2 <- ann.update %>% separate(dateread, c("date", "read"),sep = " ") 
ann.update2$read <- as.numeric(ann.update2$read)
last year raw flow
file_path_flow_prev <- here("data", "hydro", "2023-24 Water Year Transfer Packet for ICWD", "VolumeMonthAF_2023-24.dat")
ann.update_prev <- if (file.exists(file_path_flow_prev)) {
  parse_volume_dat(file_path_flow_prev)
} else {
  tibble(staid = character(), dateread = character())
}

ann.update_prev2 <- if (nrow(ann.update_prev) > 0) {
  prev2 <- ann.update_prev %>% separate(dateread, c("date", "read"), sep = " ")
  prev2$read <- as.numeric(prev2$read)
  prev2
} else {
  tibble(staid = character(), date = character(), read = numeric())
}

Raw input coverage

The table below summarizes the raw input coverage for the current and prior water years. Use this to confirm record counts and date ranges before merging with historical totals.

raw input coverage
raw_flow_coverage <- tibble(
  File = c(basename(file_path_flow), basename(file_path_flow_prev)),
  Records = c(nrow(ann.update2), nrow(ann.update_prev2)),
  Unique_staids = c(
    dplyr::n_distinct(ann.update2$staid),
    dplyr::n_distinct(ann.update_prev2$staid)
  ),
  Date_start = c(
    min(ymd_hms(ann.update2$date), na.rm = TRUE),
    if (nrow(ann.update_prev2) > 0) min(ymd_hms(ann.update_prev2$date), na.rm = TRUE) else as.POSIXct(NA)
  ),
  Date_end = c(
    max(ymd_hms(ann.update2$date), na.rm = TRUE),
    if (nrow(ann.update_prev2) > 0) max(ymd_hms(ann.update_prev2$date), na.rm = TRUE) else as.POSIXct(NA)
  )
)

knitr::kable(raw_flow_coverage)
File Records Unique_staids Date_start Date_end
VolumeMonthAF_2024-25.dat 8853 744 2024-10-01 2025-09-01
VolumeMonthAF_2023-24.dat 8796 744 2023-10-01 2024-09-01
date columns
ann.update2$datetime <- ymd_hms(ann.update2$date)


ann.update3<-ann.update2 %>% 
  mutate(year = year(datetime),
         month = month(datetime),
         month_abbr = month(datetime, label = TRUE, abbr = TRUE),
         day = mday(datetime),
         date.y.m.d = make_date(year, month,day))

ann.update4<-ann.update3 %>% 
  select(-date) %>% 
  rename(date = date.y.m.d) %>% 
  left_join(staidlist, by = 'staid') %>% 
  select(staid,description,read,date,year,month,month_abbr) 

ann.update5 <- ann.update4 %>% 
  mutate(wy = case_when(month >= 10 ~ year + 1,
                        month <= 9 ~ year),
         roy = case_when(month <= 3 ~ year - 1,
                         month >= 4 ~ year),
         irrag = case_when(month %in% c(4,5,6,7,8,9) ~ year))
summary stations
if (nrow(sum_stations) > 0) {
  sstations <- sum_stations %>% 
    gather(staid,read,-date) %>% 
    left_join(staidlist, by = 'staid') %>% 
    select(-desc_id) %>% 
    mutate(date = as.Date(date),
           datetime = as.Date(date),
           month = month(datetime),
           year = year(datetime),
           month_abbr = month(datetime, label = TRUE, abbr = TRUE))
} else {
  sstations <- ann.update4 %>% slice(0)
}
split into ovga and ic updates
update.merged.ic <- bind_rows(sstations,ann.update4)
update.merged <- ann.update4

Data integration and outputs

ICWD flow export

IC date columns
update.merged.ic <- update.merged.ic %>% 
  mutate(wy = case_when(month >= 10 ~ year + 1,
                        month <= 9 ~ year),
         roy = case_when(month <= 3 ~ year - 1,
                         month >= 4 ~ year),
         irrag = case_when(month %in% c(4,5,6,7,8,9) ~ year))
update IC DB
totals_means <- bind_rows(update.merged.ic, monthlies) %>%
  mutate(date = as.Date(date))
write updated IC DB
totals_means %>% write_csv(here('data','hydro','totals_means_thru_sep_2025.csv'))
out_icwd <- here("output", "2025wy", "ICWD")
dir.create(out_icwd, recursive = TRUE, showWarnings = FALSE)
totals_means %>% write_csv(file.path(out_icwd, "totals_means_thru_sep_2025.csv"))

OVGA exports and QA/QC

OVGA flow export

Flow exports are derived from the monthly flow records after joining flow metadata and filtering to valid OVGA monitoring points. The output is formatted to the OVGA surface-water import template. The OVGA template reuses the “WellName” column for both wells and gages; in the flow export that column holds gage IDs (e.g. SW0373), not well names.

filter no value -777
tm <- ann.update5 %>% filter(read != -777)
split flow vs production
normalize_flow_staid <- function(x) {
  ifelse(str_detect(x, "^0"), str_replace(x, "^0+", ""), x)
}

flowdf <- tm %>% 
  filter(!str_detect(staid, "[[:alpha:]]")) %>%
  mutate(staid = normalize_flow_staid(staid))

flow_ovga <- flowdf %>%
  left_join(flow.meta, by = 'staid') %>%
  filter(!is.na(mon_pt_name), mon_pt_name != "")

proddf <- tm %>% filter(str_detect(staid, "[[:alpha:]]"))
ovga flow columns
sw.flow <- flow_ovga  %>% 
  mutate(WellName = mon_pt_name,
         FlowDate = date,
         FlowRateAcFtPM = read,
         FlowRateCFS = "",
         FlowRateGPM = "") %>% 
  select(WellName,FlowDate,FlowRateAcFtPM, FlowRateCFS, FlowRateGPM)

sw.flow %>%
  datatable(
    options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE),
    class = "compact stripe"
  )
find/remove duplicates
dupes <- sw.flow %>% get_dupes() 

swflow2 <- sw.flow %>% anti_join(dupes)
write ovga flow
out_ovga <- here("output", "2025wy", "OVGA")
dir.create(out_ovga, recursive = TRUE, showWarnings = FALSE)
swflow2 %>% write_csv(file.path(out_ovga, paste0("ovga_swflow_import_wy", wy_short, ".csv")))
OVGA flow import metrics
flow_import_metrics <- tibble(
  Metric = c(
    "Flow records in upload",
    "Unique monitoring points",
    "Date range"
  ),
  Value = c(
    nrow(swflow2),
    dplyr::n_distinct(swflow2$WellName),
    paste0(min(swflow2$FlowDate, na.rm = TRUE), " to ", max(swflow2$FlowDate, na.rm = TRUE))
  )
) %>% mutate(Value = format(Value, big.mark = ","))

knitr::kable(flow_import_metrics)
Metric Value
Flow records in upload 6611
Unique monitoring points 555
Date range 2024-10-01 to 2025-09-01

Flow export comparison (last year vs current)

This comparison highlights which flow gages were added or removed from the OVGA export between WY2024 and WY2025.

code
flow_export_last_path <- here("output", "ovga_swflow_import_wy24.csv")
flow_export_last <- if (file.exists(flow_export_last_path)) {
  read_csv(flow_export_last_path, show_col_types = FALSE) %>% distinct(WellName)
} else {
  tibble(WellName = character())
}

flow_export_current <- swflow2 %>% distinct(WellName)

flow_export_missing <- flow_export_last %>% anti_join(flow_export_current, by = "WellName")
flow_export_new <- flow_export_current %>% anti_join(flow_export_last, by = "WellName")

DT::datatable(
  flow_export_missing,
  options = list(pageLength = 10, lengthChange = TRUE),
  caption = "Flow gages in WY2024 export but missing in WY2025 export"
)
code
DT::datatable(
  flow_export_new,
  options = list(pageLength = 10, lengthChange = TRUE),
  caption = "Flow gages in WY2025 export but not in WY2024 export"
)

OVGA production export

Production exports are derived from monthly production records after joining production metadata and filtering to valid OVGA monitoring points. Missing method values are defaulted to “Unknown” to satisfy the OVGA template.

ovga production columns
prod_ovga <- proddf %>%
  left_join(production.meta, by = c('staid' = 'mon_pt_name')) %>%
  semi_join(ovga_points, by = c("staid" = "mon_pt_name"))

tm.well.flow <- prod_ovga %>% 
  mutate(WellName = staid,
         StartDate = date,
         EndDate = ceiling_date(date,'month'),
         AgUsageAF = '',
         MIUsageAF = read,
         TotalUsageAF = read,
         Method = if_else(is.na(method) | method == "", "Unknown", method),
         Notes = '') %>% 
  select(WellName,StartDate,EndDate, AgUsageAF, MIUsageAF, TotalUsageAF, Method, Notes)
code
tm.well.flow %>%
  datatable(
    options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE),
    class = "compact stripe"
  )
find/replace duplicates
dupeswell <- tm.well.flow %>% get_dupes()
if (nrow(dupeswell) == 0) {
  cat("No duplicates found.\n")
} else {
  cat("Duplicate rows found: ", nrow(dupeswell), "\n")
  dupeswell
}
No duplicates found.
write ovga production
out_ovga <- here("output", "2025wy", "OVGA")
dir.create(out_ovga, recursive = TRUE, showWarnings = FALSE)
tm.well.flow %>% write_csv(file.path(out_ovga, paste0("ovga_production_import_wy", wy_range, ".csv")))
writexl::write_xlsx(tm.well.flow, file.path(out_ovga, paste0("ovga_production_import_wy", wy_range, ".xlsx")))
OVGA production import metrics
production_import_metrics <- tibble(
  Metric = c(
    "Production records in upload",
    "Unique production wells",
    "Date range"
  ),
  Value = c(
    nrow(tm.well.flow),
    dplyr::n_distinct(tm.well.flow$WellName),
    paste0(min(tm.well.flow$StartDate, na.rm = TRUE), " to ", max(tm.well.flow$EndDate, na.rm = TRUE))
  )
) %>% mutate(Value = format(Value, big.mark = ","))

knitr::kable(production_import_metrics)
Metric Value
Production records in upload 1956
Unique production wells 162
Date range 2024-10-01 to 2025-10-01

Production export comparison (last year vs current)

This comparison highlights which production wells were added or removed from the OVGA export between WY2024 and WY2025.

code
production_export_last_path <- here("output", "ovga_production_import_wy23-24.csv")
production_export_last <- if (file.exists(production_export_last_path)) {
  read_csv(production_export_last_path, show_col_types = FALSE) %>% distinct(WellName)
} else {
  tibble(WellName = character())
}

production_export_current <- tm.well.flow %>% distinct(WellName)

production_export_missing <- production_export_last %>% anti_join(production_export_current, by = "WellName")
production_export_new <- production_export_current %>% anti_join(production_export_last, by = "WellName")

DT::datatable(
  production_export_missing,
  options = list(pageLength = 10, lengthChange = TRUE),
  caption = "Production wells in WY2024 export but missing in WY2025 export"
)
code
DT::datatable(
  production_export_new,
  options = list(pageLength = 10, lengthChange = TRUE),
  caption = "Production wells in WY2025 export but not in WY2024 export"
)

Raw data exclusions (last year vs current)

These tables show staids present in the raw input data but excluded from the export, which helps isolate metadata gaps or OVGA list mismatches.

code
raw_flow_current <- ann.update2 %>%
  filter(!str_detect(staid, "[[:alpha:]]")) %>%
  mutate(staid = normalize_flow_staid(staid))

raw_flow_prev <- ann.update_prev2 %>%
  filter(!str_detect(staid, "[[:alpha:]]")) %>%
  mutate(staid = normalize_flow_staid(staid))

raw_prod_current <- ann.update2 %>%
  filter(str_detect(staid, "[[:alpha:]]"))

raw_prod_prev <- ann.update_prev2 %>%
  filter(str_detect(staid, "[[:alpha:]]"))

raw_flow_current_joined <- raw_flow_current %>%
  left_join(flow.meta, by = "staid")

raw_flow_prev_joined <- raw_flow_prev %>%
  left_join(flow.meta, by = "staid")

raw_flow_excluded_current <- raw_flow_current_joined %>%
  filter(is.na(mon_pt_name) | !(mon_pt_name %in% flow_export_current$WellName)) %>%
  distinct(staid, mon_pt_name) %>%
  arrange(staid)

raw_flow_excluded_prev <- raw_flow_prev_joined %>%
  filter(is.na(mon_pt_name) | !(mon_pt_name %in% flow_export_last$WellName)) %>%
  distinct(staid, mon_pt_name) %>%
  arrange(staid)

raw_prod_excluded_current <- raw_prod_current %>%
  distinct(staid) %>%
  filter(!staid %in% production_export_current$WellName) %>%
  arrange(staid)

raw_prod_excluded_prev <- raw_prod_prev %>%
  distinct(staid) %>%
  filter(!staid %in% production_export_last$WellName) %>%
  arrange(staid)

DT::datatable(
  raw_flow_excluded_prev,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE),
  caption = "Flow staids in WY2024 raw data excluded from WY2024 export"
)
code
DT::datatable(
  raw_flow_excluded_current,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE),
  caption = "Flow staids in WY2025 raw data excluded from WY2025 export"
)
code
DT::datatable(
  raw_prod_excluded_prev,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE),
  caption = "Production staids in WY2024 raw data excluded from WY2024 export"
)
code
DT::datatable(
  raw_prod_excluded_current,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE),
  caption = "Production staids in WY2025 raw data excluded from WY2025 export"
)

Removed staids not on the basin list are excluded from OVGA exports and reflected in the raw‑data exclusion tables above.

OVGA flow and production exclusions (download)

flow/production staids excluded from OVGA exports
flow_exclusions <- flowdf %>%
  left_join(flow.meta, by = "staid") %>%
  filter(is.na(mon_pt_name) | mon_pt_name == "") %>%
  group_by(staid) %>%
  summarise(
    records = n(),
    first_date = min(date, na.rm = TRUE),
    last_date = max(date, na.rm = TRUE),
    mon_pt_name = dplyr::first(mon_pt_name),
    .groups = "drop"
  ) %>%
  left_join(staidlist, by = "staid") %>%
  mutate(
    dataset = "flow",
    reason = "Missing flow metadata mapping (Flows.csv)"
  ) %>%
  select(
    dataset,
    staid,
    description,
    records,
    first_date,
    last_date,
    mon_pt_name,
    reason
  ) %>%
  arrange(staid)

prod_exclusions <- proddf %>%
  anti_join(ovga_points, by = c("staid" = "mon_pt_name")) %>%
  left_join(production.meta, by = c("staid" = "mon_pt_name")) %>%
  group_by(staid) %>%
  summarise(
    records = n(),
    first_date = min(date, na.rm = TRUE),
    last_date = max(date, na.rm = TRUE),
    method = dplyr::first(method),
    .groups = "drop"
  ) %>%
  left_join(staidlist, by = "staid") %>%
  mutate(
    dataset = "production",
    reason = "Not in OVGA monitoring points list"
  ) %>%
  select(
    dataset,
    staid,
    description,
    records,
    first_date,
    last_date,
    method,
    reason
  ) %>%
  arrange(staid)

out_ovga <- here("output", "2025wy", "OVGA")
dir.create(out_ovga, recursive = TRUE, showWarnings = FALSE)

flow_exclusions %>%
  write_csv(file.path(out_ovga, paste0("ovga_flow_exclusions_wy", wy_year, ".csv")))
writexl::write_xlsx(flow_exclusions, file.path(out_ovga, paste0("ovga_flow_exclusions_wy", wy_year, ".xlsx")))
prod_exclusions %>%
  write_csv(file.path(out_ovga, paste0("ovga_production_exclusions_wy", wy_year, ".csv")))
writexl::write_xlsx(prod_exclusions, file.path(out_ovga, paste0("ovga_production_exclusions_wy", wy_year, ".xlsx")))

dtw_exclusions_path <- file.path(out_ovga, "ovga_dtw_exclusions_wy2025.csv")
if (file.exists(dtw_exclusions_path)) {
  dtw_exclusions <- read_csv(dtw_exclusions_path, show_col_types = FALSE)
  ovga_exclusions <- bind_rows(dtw_exclusions, flow_exclusions, prod_exclusions)
  ovga_exclusions %>%
    write_csv(file.path(out_ovga, paste0("ovga_exclusions_wy", wy_year, ".csv")))
  writexl::write_xlsx(ovga_exclusions, file.path(out_ovga, paste0("ovga_exclusions_wy", wy_year, ".xlsx")))
}

DT::datatable(
  flow_exclusions,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE),
  class = "compact stripe",
  caption = "Flow staids excluded from OVGA export (WY2025)"
)
flow/production staids excluded from OVGA exports
DT::datatable(
  prod_exclusions,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE),
  class = "compact stripe",
  caption = "Production staids excluded from OVGA export (WY2025)"
)

Results: QA/QC and plots

This section provides QA/QC plots and summaries for flow, production, and linked well pumping. It includes time-series plots, seasonal totals, and percentile comparisons to support review before export.

Totals and Means - summary stations requested

Summary-station series (OVR, OVPW, etc.) are populated from the summary stations file (2025 - Inyo County Summary Stations.xlsx). These plots now include data through WY2025.

OVR Owens Valley Runoff

code
tm2 <- totals_means
monthly_uses_syncplot(staid_p = "OVR" ,data = tm2, group_name = 'ov')

Owens Valley Runoff

OVPW Owens Valley Pumped Water

code
monthly_uses_syncplot(staid_p = "OVPW" ,data = tm2, group_name = 'ov')

Owens Valley Pumped Water

FTC L.A.A. Total Flow to the City

code
monthly_uses_syncplot(staid_p = "FTC" ,data = tm2, group_name = 'ov')

LAA Total Flow to the City

LOLU Lower Owens River Project – Lakes and Ponds Use

code
monthly_uses_syncplot(staid_p = "LOLU" ,data = tm2, group_name = 'ov')

Lower Owens River Project – Lakes and Ponds Use

LOOU Lower Owens River Project – Operations

code
monthly_uses_syncplot(staid_p = "LOOU" ,data = tm2, group_name = 'ov')

Lower Owens River Project – Operations

LORPDU Lower Owens River – Delta Uses

code
monthly_uses_syncplot(staid_p = "LORPDU" ,data = tm2, group_name = 'ov')

Lower Owens River – Delta Uses

LORPRU Lower Owens River – River Uses

code
monthly_uses_syncplot(staid_p = "LORPRU" ,data = tm2, group_name = 'ov')

Lower Owens River – River Uses

LORPTU Lower Owens River Project Total Uses

code
monthly_uses_syncplot(staid_p = "LORPTU" ,data = tm2, group_name = 'ov')

Lower Owens River Project Total Uses

LOWU Lower Owens River Project – Waterfowl Uses

code
monthly_uses_syncplot(staid_p = "LOWU" ,data = tm2, group_name = 'ov')

Lower Owens River Project – Waterfowl Uses

MBR Mono Basin Runoff

code
monthly_uses_syncplot(staid_p = "MBR" ,data = tm2, group_name = 'ov')

Mono Basin Runoff

MTWP Mono Tunnel at West Portal

code
monthly_uses_syncplot(staid_p = "MTWP" ,data = tm2, group_name = 'ov')

Mono Tunnel at West Portal

OLTU Owens Lake Total Uses

code
monthly_uses_syncplot(staid_p = "OLTU" ,data = tm2, group_name = 'ov')

Owens Lake Total Uses

OVFG Owens Valley Flowing Groundwater

code
monthly_uses_syncplot(staid_p = "OVFG" ,data = tm2, group_name = 'ov')

Owens Valley Flowing Groundwater

OVGR Owens Valley Groundwater Recharge

code
monthly_uses_syncplot(staid_p = "OVGR" ,data = tm2, group_name = 'ov')

Owens Valley Groundwater Recharge

OVIR Owens Valley Irrigation

code
monthly_uses_syncplot(staid_p = "OVIR" ,data = tm2, group_name = 'ov')

Owens Valley Irrigation

SHTO South Haiwee Total Outflow (1+2+3+BP)

code
monthly_uses_syncplot(staid_p = "SHTO" ,data = tm2, group_name = 'ov')

South Haiwee Total Outflow (1+2+3+BP)

Stream flow

Top 10 stream gages by WY2025 volume (water year Oct-Sep)

code
flow_wy <- flowdf %>%
  mutate(wy = if_else(month >= 10, year + 1, year))

top_streams <- flow_wy %>%
  filter(wy == wy_year) %>%
  group_by(staid) %>%
  summarise(total_af = sum(read, na.rm = TRUE), .groups = "drop") %>%
  arrange(desc(total_af)) %>%
  slice_head(n = 10) %>%
  left_join(flow.meta, by = "staid")

top_streams <- top_streams %>%
  mutate(display_name = if_else(is.na(mon_pt_name) | mon_pt_name == "", staid, mon_pt_name))

plots <- purrr::map2(top_streams$staid, top_streams$display_name, ~{
  plot_title <- paste0(.y, " (", .x, ")")
  htmltools::tagList(
    htmltools::tags$h4(plot_title),
    monthly_flow_syncplot_staid(staid_p = .x, data = totals_means, group_name = "a", title = plot_title)
  )
})

htmltools::tagList(plots)

SW0050 (50)

SW0077 (77)

SW2066 (2066)

SW0049 (49)

413 (413)

SW3289 (3289)

SW0118 (118)

SW0116 (116)

SW3324 (3324)

SW0088 (88)

Pumping

Runoff year (Apr-Mar) pumping totals are reported below. Water year (Oct-Sep) is used for the flow series above. RY2025 totals are partial because Oct–Mar 2025–26 data have not yet been delivered.

Production well locations and WY2025 pumping

production well map by annual pumping
# Calculate WY2025 total pumping by well
wy2025_pumping <- proddf %>%
  filter(year == 2024 & month >= 10 | year == 2025 & month <= 9) %>%
  mutate(read = if_else(read == -777, 0, read)) %>%
  group_by(staid) %>%
  summarise(
    total_pumping_af = sum(read, na.rm = TRUE),
    n_months = n(),
    .groups = "drop"
  ) %>%
  filter(total_pumping_af > 0)

# Join with monitoring points for locations
pumping_map_data <- wy2025_pumping %>%
  left_join(ovga_points, by = c("staid" = "mon_pt_name")) %>%
  filter(!is.na(mon_pt_lat), !is.na(mon_pt_long))

# Create leaflet map with circle markers sized by pumping volume
if (nrow(pumping_map_data) > 0) {
  # Scale radius: sqrt transformation for better visual distribution
  max_pumping <- max(pumping_map_data$total_pumping_af, na.rm = TRUE)
  pumping_map_data <- pumping_map_data %>%
    mutate(
      radius = sqrt(total_pumping_af / max_pumping) * 20 + 5,
      popup_text = paste0(
        "<b>", staid, "</b><br>",
        "WY2025 Pumping: ", format(round(total_pumping_af, 0), big.mark = ","), " AF<br>",
        "Months reported: ", n_months
      )
    )
  
  leaflet(pumping_map_data) %>%
    addProviderTiles(providers$CartoDB.Positron) %>%
    addCircleMarkers(
      lng = ~mon_pt_long,
      lat = ~mon_pt_lat,
      radius = ~radius,
      color = "#2E86AB",
      fillColor = "#2E86AB",
      fillOpacity = 0.6,
      stroke = TRUE,
      weight = 1,
      popup = ~popup_text,
      label = ~staid
    ) %>%
    addLegend(
      position = "bottomright",
      colors = c("#2E86AB", "#2E86AB", "#2E86AB"),
      labels = c(
        paste0("Small (<", format(round(max_pumping * 0.1, 0), big.mark = ","), " AF)"),
        paste0("Medium (~", format(round(max_pumping * 0.5, 0), big.mark = ","), " AF)"),
        paste0("Large (>", format(round(max_pumping * 0.9, 0), big.mark = ","), " AF)")
      ),
      title = "WY2025 Total Pumping",
      opacity = 0.8
    )
} else {
  cat("No production wells with location data available for mapping.")
}

Annual pumping by wellfield

code
tozoo <- function(x) zoo(x$total_pumping, x$roy2)
dyMultiColumn <- function(dygraph) {
  dyPlotter(dygraph = dygraph,
            name = "MultiColumn",
            path = system.file("plotters/multicolumn.js",
                               package = "dygraphs"))
}

plot_wellfield_total <- function(data, field_sites, linked_wells_sites, title, value_range = NULL) {
  linked_wells <- linked_wells_sites %>%
    filter(Site %in% field_sites) %>%
    pull(Linked_Well) %>%
    unique()

  if (length(linked_wells) == 0) {
    return(NULL)
  }

  df <- data %>%
    filter(staid %in% linked_wells) %>%
    mutate(read = if_else(read == -777, 0, read)) %>%
    group_by(roy, staid) %>%
    summarise(total_pumping = sum(read, na.rm = TRUE), .groups = "drop") %>%
    mutate(roy2 = make_date(roy, 4, 1))

  df <- pad_series_bounds(df, "roy2", "staid", months_start = 6, months_end = 18)

  year_totals <- df %>%
    group_by(roy2) %>%
    summarise(total_pumping = sum(total_pumping, na.rm = TRUE), .groups = "drop")
  y_max <- max(year_totals$total_pumping, na.rm = TRUE)

  plot_data <- do.call(merge, lapply(split(df, df$staid), tozoo))
  plot <- plot_data %>%
    dygraph(main = title, group = "a") %>%
    dyStackedBarGroup(linked_wells) %>%
    dyBarChart() %>%
    dyAxis("y", label = "Total Annual Pumping (AF)", axisLabelWidth = 70)

  if (is.finite(y_max)) {
    plot <- plot %>% dyAxis("y", valueRange = c(0, y_max * 1.05))
  }

  if (!is.null(value_range)) {
    plot <- plot %>% dyAxis("y", valueRange = value_range)
  }

  plot
}

compute_linked_seasonal <- function(data, linked_wells_sites, group_map, group_col, target_year = 2025) {
  base <- data %>%
    filter(staid %in% linked_wells_sites$Linked_Well) %>%
    mutate(
      read = if_else(read < 0, 0, read),
      date = as.Date(date),
      year = year(date),
      month = month(date),
      wy = if_else(month >= 10, year + 1, year),
      runoff_year = if_else(month >= 4, year, year - 1),
      season = case_when(
        month %in% 4:9 ~ "Apr-Sep",
        month %in% c(10, 11, 12, 1, 2, 3) ~ "Oct-Mar",
        TRUE ~ NA_character_
      )
    ) %>%
    left_join(group_map, by = c("staid" = "Linked_Well")) %>%
    filter(!is.na(.data[[group_col]]))

  seasonal_wy <- base %>%
    group_by(.data[[group_col]], wy, season) %>%
    summarise(total_pumping = sum(read, na.rm = TRUE), .groups = "drop") %>%
    pivot_wider(names_from = season, values_from = total_pumping) %>%
    rename(Group = !!sym(group_col), WY = wy) %>%
    arrange(Group, WY)

  runoff_ry <- base %>%
    group_by(.data[[group_col]], runoff_year) %>%
    summarise(`Apr-Mar` = sum(read, na.rm = TRUE), .groups = "drop") %>%
    rename(Group = !!sym(group_col), RY = runoff_year) %>%
    arrange(Group, RY)

  percentiles_wy <- seasonal_wy %>%
    group_by(Group) %>%
    mutate(
      `Apr-Sep pct` = if_else(
        is.na(`Apr-Sep`),
        NA_real_,
        round(dplyr::percent_rank(`Apr-Sep`) * 100, 1)
      ),
      `Oct-Mar pct` = if_else(
        is.na(`Oct-Mar`),
        NA_real_,
        round(dplyr::percent_rank(`Oct-Mar`) * 100, 1)
      )
    ) %>%
    ungroup() %>%
    filter(WY == target_year) %>%
    select(Group, WY, `Apr-Sep`, `Apr-Sep pct`, `Oct-Mar`, `Oct-Mar pct`)

  percentiles_ry <- runoff_ry %>%
    group_by(Group) %>%
    mutate(
      `Apr-Mar pct` = if_else(
        is.na(`Apr-Mar`),
        NA_real_,
        round(dplyr::percent_rank(`Apr-Mar`) * 100, 1)
      )
    ) %>%
    ungroup() %>%
    filter(RY == target_year) %>%
    select(Group, RY, `Apr-Mar`, `Apr-Mar pct`)

  list(
    seasonal_wy = seasonal_wy,
    runoff_ry = runoff_ry,
    percentiles_wy = percentiles_wy,
    percentiles_ry = percentiles_ry
  )
}

Linked Wells QA/QC

Linked-well pumping plots from the on/off management program support visual QA/QC of annual totals.

code
tm2 <- totals_means

linked_wells_sites <- data.frame(
  Site = rep(
    c(
      "BP1", "BP2", "BP3", "BP4",
      "LW1", "LW2", "LW3",
      "TA3", "TA4", "TA5", "TA6",
      "TS1", "TS2", "TS3", "TS4",
      "IO1", "IO2",
      "SS1", "SS2", "SS3", "SS4",
      "BG2"
    ),
    times = c(4, 4, 4, 1, 4, 4, 5, 4, 2, 1, 2, 1, 1, 3, 2, 3, 1, 3, 3, 2, 2, 4)
  ),
  Linked_Well = c(
    "W210", "W378", "W379", "W389",   # BP1
    "W220", "W229", "W374", "W375",   # BP2
    "W222", "W223", "W231", "W232",   # BP3
    "W331",                            # BP4
    "W247", "W248", "W249", "W398",   # LW1
    "W236", "W239", "W243", "W244",   # LW2
    "W240", "W241", "W399", "W376", "W377", # LW3
    "W106", "W110", "W111", "W114",   # TA3
    "W342", "W347",                   # TA4
    "W349",                           # TA5
    "W109", "W370",                   # TA6
    "W159",                           # TS1
    "W155",                           # TS2
    "W103", "W104", "W382",           # TS3
    "W380", "W381",                   # TS4
    "W061", "W391", "W400",           # IO1
    "W063",                           # IO2
    "W069", "W392", "W393",           # SS1
    "W074", "W394", "W395",           # SS2
    "W092", "W396",                   # SS3
    "W075", "W345",                   # SS4
    "W076", "W403", "W343", "W348"    # BG2
  )
)

wellfield_sites <- tibble(
  Wellfield = c(
    "Laws",
    "Big Pine",
    "Taboose/Aberdeen",
    "Thibaut/Sawmill",
    "Independence/Oak",
    "Symmes/Shepherd",
    "Bairs/Georges"
  ),
  Site = I(list(
    c("LW1", "LW2", "LW3"),
    c("BP1", "BP2", "BP3", "BP4"),
    c("TA3", "TA4", "TA5", "TA6"),
    c("TS1", "TS2", "TS3", "TS4"),
    c("IO1", "IO2"),
    c("SS1", "SS2", "SS3", "SS4"),
    c("BG2")
  ))
) %>%
  unnest(Site)

site_map <- linked_wells_sites %>% select(Linked_Well, Site)
wellfield_map <- linked_wells_sites %>%
  left_join(wellfield_sites, by = "Site") %>%
  select(Linked_Well, Wellfield)

seasonal_by_site <- compute_linked_seasonal(tm2, linked_wells_sites, site_map, "Site", target_year = wy_year)
seasonal_by_wellfield <- compute_linked_seasonal(tm2, linked_wells_sites, wellfield_map, "Wellfield", target_year = wy_year)

Seasonal totals use water year (WY, Oct–Sep end year) for Apr–Sep and Oct–Mar. Runoff totals use runoff year (RY, Apr–Mar start year). Example: RY2025 = Apr 2025–Mar 2026.

Seasonal pumping totals by site (water year Oct-Sep)

code
DT::datatable(
  seasonal_by_site$seasonal_wy,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE)
)

Runoff year totals by site (Apr-Mar)

code
DT::datatable(
  seasonal_by_site$runoff_ry,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE)
)

WY2025 percentiles by site (water year Oct-Sep)

Percentiles are calculated within each site using the historical distribution of Apr–Sep totals (and separately Oct–Mar totals). WY2025 percentiles therefore rank the 2025 six‑month total against all prior years for that site.

code
DT::datatable(
  seasonal_by_site$percentiles_wy,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE)
)

RY2025 percentiles by site (runoff year Apr-Mar)

code
DT::datatable(
  seasonal_by_site$percentiles_ry,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE)
)

Seasonal pumping totals by wellfield (water year Oct-Sep)

code
DT::datatable(
  seasonal_by_wellfield$seasonal_wy,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE)
)

Runoff year totals by wellfield (Apr-Mar)

code
DT::datatable(
  seasonal_by_wellfield$runoff_ry,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE)
)

WY2025 percentiles by wellfield (water year Oct-Sep)

Percentiles are calculated within each wellfield using the historical distribution of Apr–Sep totals (and separately Oct–Mar totals). WY2025 percentiles therefore rank the 2025 six‑month total against all prior years for that wellfield.

code
DT::datatable(
  seasonal_by_wellfield$percentiles_wy,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE)
)

RY2025 percentiles by wellfield (runoff year Apr-Mar)

code
DT::datatable(
  seasonal_by_wellfield$percentiles_ry,
  options = list(pageLength = 10, lengthChange = TRUE, scrollX = TRUE)
)

Laws Monitoring Site #1 (LW1)

code
plot_linked_wells(tm2, "LW1", linked_wells_sites)

Laws Monitoring Site #2 (LW2)

code
plot_linked_wells(tm2, "LW2", linked_wells_sites)

Laws Monitoring Site #3 (LW3)

code
plot_linked_wells(tm2, "LW3", linked_wells_sites)

Laws Wellfield Total Pumping

code
plot_wellfield_total(
  tm2,
  field_sites = c("LW1", "LW2", "LW3"),
  linked_wells_sites = linked_wells_sites,
  title = "Laws Wellfield Total Pumping"
)

Big Pine Monitoring Site #1 (BP1)

code
plot_linked_wells(tm2, "BP1", linked_wells_sites)

Big Pine Monitoring Site #2 (BP2)

code
plot_linked_wells(tm2, "BP2", linked_wells_sites)

Big Pine Monitoring Site #3 (BP3)

code
plot_linked_wells(tm2, "BP3", linked_wells_sites)

Big Pine Monitoring Site #4 (BP4)

code
plot_linked_wells_single(tm2, "BP4", linked_wells_sites)

Exempt Wells - Fish Hatchery and 218, 219

code
linked_wells <- c("W218","W219","W330","W332")

DF <- tm2 %>%
  filter(staid %in% linked_wells) %>%
  mutate(read = if_else(read == -777, 0, read)) %>%
  group_by(roy, staid) %>%
  summarise(total_pumping = sum(read, na.rm = TRUE), .groups = "drop") %>%
  mutate(roy2 = make_date(roy, 4, 1))

DF <- pad_series_bounds(DF, "roy2", "staid", months_start = 6, months_end = 18)

Data <- do.call(merge, lapply(split(DF, DF$staid), tozoo))
year_totals_exempt <- DF %>%
  group_by(roy2) %>%
  summarise(total_pumping = sum(total_pumping, na.rm = TRUE), .groups = "drop")
y_max_exempt <- max(year_totals_exempt$total_pumping, na.rm = TRUE)

plot_exempt <- Data %>% dygraph(group = "a") %>%
  dyStackedBarGroup(c("W218","W219","W330","W332")) %>%
  dyBarChart() %>%
  dyAxis("y", label = "Total Annual Pumping (AF)", axisLabelWidth = 70)

if (is.finite(y_max_exempt)) {
  plot_exempt <- plot_exempt %>% dyAxis("y", valueRange = c(0, y_max_exempt * 1.05))
}

plot_exempt
Figure 1: hatchery wells 330/332 and exempt wells 218/219

Big Pine Pumping Totals

code
linked_wells <- c("W210","W378","W379","W389","W220","W229","W374","W375","W222","W223","W231","W232","W331","W218","W219","W330","W332")

DF <- tm2 %>%
  filter(staid %in% linked_wells) %>%
  mutate(read = if_else(read == -777, 0, read)) %>%
  group_by(roy, staid) %>%
  summarise(total_pumping = sum(read, na.rm = TRUE), .groups = "drop") %>%
  mutate(roy2 = make_date(roy, 4, 1))

DF <- pad_series_bounds(DF, "roy2", "staid", months_start = 6, months_end = 18)

Data <- do.call(merge, lapply(split(DF, DF$staid), tozoo))
year_totals_bp <- DF %>%
  group_by(roy2) %>%
  summarise(total_pumping = sum(total_pumping, na.rm = TRUE), .groups = "drop")
y_max_bp <- max(year_totals_bp$total_pumping, na.rm = TRUE)

plot_bp <- Data %>% dygraph(group = "a") %>%
  dyStackedBarGroup(c("W210","W378","W379","W389","W220","W229","W374","W375","W222","W223","W231","W232","W331","W218","W219","W330","W332")) %>%
  dyBarChart() %>%
  dyAxis("y", label = "Total Annual Pumping (AF)", axisLabelWidth = 70)

if (is.finite(y_max_bp)) {
  plot_bp <- plot_bp %>% dyAxis("y", valueRange = c(0, y_max_bp * 1.05))
}

plot_bp

Big Pine Wellfield Pumping

Taboose/Aberdeen Monitoring Site #3 (TA3)

code
plot_linked_wells(tm2, "TA3", linked_wells_sites)

Taboose/Aberdeen Monitoring Site #4 (TA4)

code
plot_linked_wells(tm2, "TA4", linked_wells_sites)

Taboose/Aberdeen Monitoring Site #5 (TA5)

code
plot_linked_wells_single(tm2, "TA5", linked_wells_sites)

Taboose/Aberdeen Monitoring Site #6 (TA6)

code
plot_linked_wells(tm2, "TA6", linked_wells_sites)

Taboose/Aberdeen Wellfield Total Pumping

code
plot_wellfield_total(
  tm2,
  field_sites = c("TA3", "TA4", "TA5", "TA6"),
  linked_wells_sites = linked_wells_sites,
  title = "Taboose/Aberdeen Wellfield Total Pumping"
)

Thibaut/Sawmill Monitoring Site #1 (TS1)

code
plot_linked_wells_single(tm2, "TS1", linked_wells_sites)

Thibaut/Sawmill Monitoring Site #2 (TS2)

code
plot_linked_wells_single(tm2, "TS2", linked_wells_sites)

Thibaut/Sawmill Monitoring Site #3 (TS3)

code
plot_linked_wells(tm2, "TS3", linked_wells_sites)

Thibaut/Sawmill Monitoring Site #4 (TS4)

code
plot_linked_wells(tm2, "TS4", linked_wells_sites)

Thibaut/Sawmill Wellfield Total Pumping

code
plot_wellfield_total(
  tm2,
  field_sites = c("TS1", "TS2", "TS3", "TS4"),
  linked_wells_sites = linked_wells_sites,
  title = "Thibaut/Sawmill Wellfield Total Pumping"
)

Independence/Oak Monitoring Site #1 (IO1)

code
plot_linked_wells(tm2, "IO1", linked_wells_sites)

Independence/Oak Monitoring Site #2 (IO2)

code
plot_linked_wells_single(tm2, "IO2", linked_wells_sites)

Independence/Oak Wellfield Total Pumping

code
plot_wellfield_total(
  tm2,
  field_sites = c("IO1", "IO2"),
  linked_wells_sites = linked_wells_sites,
  title = "Independence/Oak Wellfield Total Pumping"
)

Symmes/Shepherd Monitoring Site #1 (SS1)

code
plot_linked_wells(tm2, "SS1", linked_wells_sites)

Symmes/Shepherd Monitoring Site #2 (SS2)

code
plot_linked_wells(tm2, "SS2", linked_wells_sites)

Symmes/Shepherd Monitoring Site #3 (SS3)

code
plot_linked_wells(tm2, "SS3", linked_wells_sites)

Symmes/Shepherd Monitoring Site #4 (SS4)

code
plot_linked_wells(tm2, "SS4", linked_wells_sites)

Symmes/Shepherd Wellfield Total Pumping

code
plot_wellfield_total(
  tm2,
  field_sites = c("SS1", "SS2", "SS3", "SS4"),
  linked_wells_sites = linked_wells_sites,
  title = "Symmes/Shepherd Wellfield Total Pumping"
)

Bairs/Georges Monitoring Site #2 (BG2)

code
plot_linked_wells(tm2, "BG2", linked_wells_sites)

Bairs/Georges Wellfield Total Pumping

code
plot_wellfield_total(
  tm2,
  field_sites = c("BG2"),
  linked_wells_sites = linked_wells_sites,
  title = "Bairs/Georges Wellfield Total Pumping"
)

Citation

BibTeX citation:
@report{county_water_department2026,
  author = {County Water Department, Inyo},
  publisher = {Inyo County Water Department},
  title = {Flow {Data}},
  date = {2026-01-31},
  url = {https://inyo-gov.github.io/hydro-data/},
  langid = {en}
}
For attribution, please cite this work as:
County Water Department, Inyo. 2026. “Flow Data.” Data Report. Inyo County Water Department. https://inyo-gov.github.io/hydro-data/.