| Title: | Epidemiological Signal Observation, Detection, Identification, and Classification |
|---|---|
| Description: | An automated surveillance system for infectious disease epidemiologists that detects aberrations in laboratory-confirmed infections, records epidemiologist's formal assessment with a full audit trail, and produces outbreak reports for clinical colleagues and infection prevention nurses. Applies evidence-based statistical methods such as Improved Farrington (2013) <doi:10.1002/sim.5595> and 'EpiEstim' (Cori et al. 2013) <doi:10.1093/aje/kwt133>. Ships with a synthetic demo data set requiring no external data or infrastructure. |
| Authors: | Matthijs S. Berends [aut, cre], Certe Medical Diagnostics & Advice Foundation [cph, fnd] (ROR: <https://ror.org/04m8g8w48>), University Medical Center Groningen [cph, fnd] (ROR: <https://ror.org/03cv38k47>) |
| Maintainer: | Matthijs S. Berends <[email protected]> |
| License: | GPL-2 |
| Version: | 0.24.0 |
| Built: | 2026-09-30 14:12:10 UTC |
| Source: | https://github.com/certe-medical-epidemiology/EpiSODIC |
Adds clusters that were never detected by EpiSODIC's own pipeline -
output from another algorithm or system, fed in as already-parsed R
values (an operator's own code is responsible for reading whatever
JSON/CSV that other system produces). A manual cluster is never
connected to this instance's own case data: it gets a real
episodic_stream identity (so it appears in the dashboard's normal
filtering, sorting and geography panels, exactly like a detected
cluster), but its case-level detail, if you supply any, is stored
separately in episodic_cluster_manual_case and never touches
episodic_case. It is therefore also never picked up by
episodic_run_cron()'s reconciliation or automatic closure - a manual
cluster only closes when an epidemiologist records that verdict.
episodic_add_manual_cluster( db_path = Sys.getenv("EPISODIC_DB", unset = NA), user_id, pathogen, level, first_day, last_day, n_cases = NA, care_line = NA, region_code = NA, institution_id = NA, ward = NA, expected = NA, excess = NA, ratio = NA, detector_agreement = 1L, priority_score = NA, case_dates = NULL, pc = NULL, sex = NULL, age = NULL, note = NA )episodic_add_manual_cluster( db_path = Sys.getenv("EPISODIC_DB", unset = NA), user_id, pathogen, level, first_day, last_day, n_cases = NA, care_line = NA, region_code = NA, institution_id = NA, ward = NA, expected = NA, excess = NA, ratio = NA, detector_agreement = 1L, priority_score = NA, case_dates = NULL, pc = NULL, sex = NULL, age = NULL, note = NA )
db_path |
Path to an existing SQLite database, or a |
user_id |
The
|
pathogen |
Raw pathogen string, exactly as it would appear in
|
level |
One of the five lattice levels: |
first_day, last_day
|
The cluster's episode window ( |
n_cases |
The case count. Optional if |
care_line, region_code, institution_id, ward
|
The remaining
|
expected, excess, ratio
|
Optional pass-through metrics, for
external algorithms that do produce a baseline comparison. |
detector_agreement |
How many independent sources agree on this
cluster. Defaults to |
priority_score |
Optional override. When |
case_dates, pc, sex, age
|
Optional per-cluster case-level detail,
each a |
note |
Optional markdown text to seed the cluster's note with
(see the dossier's notes panel), attributed to |
Every argument is vectorised: pass a single value to add one cluster,
or vectors/lists of length n to add n clusters in one call, each
getting its own new cluster id. Arguments of length 1 are recycled
against the longest argument you supply; per-cluster case-level detail
(case_dates/pc/sex/age) is never recycled - if you supply it,
it must be a list() of exactly the target length, one element (a
vector) per cluster.
The whole call is one transaction: either every requested cluster is created, or (on any validation or database error) none are.
An integer vector of the newly created cluster_id values,
one per cluster, in the same order as the arguments.
## Not run: episodic_add_manual_cluster( user_id = 1L, pathogen = "Measles virus", level = "pathogen_area", first_day = "2025-01-10", last_day = "2025-01-20", region_code = "GR", case_dates = list(as.Date(c("2025-01-10", "2025-01-14", "2025-01-20"))), pc = list(c("9711AA", "9711AB", "9712CD")), note = "Reported by municipal health service, outside our own lab data." ) ## End(Not run)## Not run: episodic_add_manual_cluster( user_id = 1L, pathogen = "Measles virus", level = "pathogen_area", first_day = "2025-01-10", last_day = "2025-01-20", region_code = "GR", case_dates = list(as.Date(c("2025-01-10", "2025-01-14", "2025-01-20"))), pc = list(c("9711AA", "9711AB", "9712CD")), note = "Reported by municipal health service, outside our own lab data." ) ## End(Not run)
There is no self-service registration: an account is added either
from the Settings screen (by an is_admin account) or with this
function at the R console. The password you supply is temporary - the
new account is flagged to require a password change, so the account
holder chooses their own password the first time they sign in.
episodic_add_user( db_path = Sys.getenv("EPISODIC_DB", unset = NA), username, full_name, email, password, role = "epidemiologist", is_admin = FALSE, must_change = TRUE )episodic_add_user( db_path = Sys.getenv("EPISODIC_DB", unset = NA), username, full_name, email, password, role = "epidemiologist", is_admin = FALSE, must_change = TRUE )
db_path |
Path to the EpiSODIC database: an existing SQLite file, or
a MariaDB/MySQL DSN (see |
username, full_name, email
|
The new account's sign-in name, display name, and email address. |
password |
A temporary plaintext password, never stored or logged as-is: it is hashed before it reaches the database, and the account holder is required to replace it on first sign-in. |
role |
Either |
is_admin |
Whether the new account may also see the Settings
screen (manage notification channels, other accounts, and export the
configuration). Independent of |
must_change |
Whether the account holder is required to replace
|
At the console, you only need to run this once per person. It opens the database, adds the account, and closes the connection again, so it is meant to be run interactively rather than from application code.
Invisibly, the new account's user_id.
db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) user_id <- episodic_add_user( db_path, username = "jdoe", full_name = "Jane Doe", email = "[email protected]", password = "temporary-password" ) user_id file.remove(db_path)db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) user_id <- episodic_add_user( db_path, username = "jdoe", full_name = "Jane Doe", email = "[email protected]", password = "temporary-password" ) user_id file.remove(db_path)
EpiSODIC's dashboard is used by a small board of epidemiologists (the
"epidemiologist" role - by definition, they assess clusters and
classify them) and by "viewer" accounts for anyone who needs to see
cluster detail, including patient-level data, without classifying
anything themselves. Both roles sign in with a username and password;
there is no self-service registration - an administrator creates each
account with episodic_add_user(), and the new user sets their
own password on first sign-in.
Passwords are hashed (never stored in plain text). Both outcomes are
recorded: a successful sign-in as a login event on the account, a
refused one - with which of unknown username, wrong password or
deactivated account it was, and the username as typed - in
episodic_app_login_failure. Both appear on the dashboard's Activity
screen, to signed-in readers only, where the sign-in category can be
filtered to on its own.
That record is an audit trail, not a defence. This login exists to attribute who assessed what, not to keep an attacker out: EpiSODIC does not implement TLS, account lockout, or rate limiting, so it should always be deployed behind your own organisation's network controls (VPN, internal network, or a reverse proxy that terminates TLS). What the failure log gives you is the ability to notice - a burst of refused attempts on one account is visible on the Activity screen the same day it happens.
EpiSODIC does not connect to any laboratory or hospital system itself.
Instead, you extract and transform your own positive-result data
beforehand - with whatever tooling you already use - and hand the result
to episodic_run_cron() as a plain data frame (a tibble is equally
fine) in the shape described here. This keeps EpiSODIC decoupled from
any specific laboratory information system: whatever your source system
is, your own extract step is the only place that needs to know about it.
A data set is the normal case, and the one to reach for. If producing the data only makes sense at run time - a live database query, for instance - you can pass a zero-argument function returning such a data set instead; EpiSODIC accepts either.
Only confirmed-positive results belong here - there is no outcome
column, so do not include negative results. If you also want a
denominator (tests performed, for a positivity rate), supply that
separately as pre-aggregated counts; see episodic_synthetic_denominators().
The only case data shipped with the package is what the synthetic
generator (episodic_synthetic_cases()) returns for the bundled
demo - a useful template for the shape your own data should have.
| Column | Data type | NA allowed |
Accepts (see below for more info) |
source_key |
character |
no | any string, unique within the data set |
lab_number |
character |
no | any string, your lab's own specimen/culture number |
patient_key |
character |
no | any string, stable per patient |
sample_date |
Date or character |
no | Format YYYY-MM-DD only |
receipt_date |
Date or character |
yes | Format YYYY-MM-DD only |
pathogen |
character |
no | free text, spelled the same every run |
care_line |
character |
yes | one of: "first", "second", "third", "other", "unknown" |
institution_key |
character |
no | any string, stable per institution |
institution_display_name |
character |
no | any string |
institution_type |
character |
no | one of: "hospital", "ltc_institution", "gp_municipality", "ooh_service", "other" |
municipality |
character |
yes | any string |
ward |
character |
yes | any string, spelled the same every run |
specialism |
character |
yes | any string |
pc |
character |
yes | postcode digits as text, or anything that connects to your maps |
sex |
character |
yes | one of: "M", "F", "U" |
age |
numeric |
yes | whole years, not an age group |
Every column is required to be present; "empty allowed" says whether
its values may be NA. Dates may be a real Date column or ISO 8601
text, and nothing else: "01-01-2025" is rejected rather than read as
the first of January in the year 1. Numbers stored as text, dates
stored as date-times, and text stored as a factor are all common
extract mistakes, and all named for what they are by the check below.
source_keyCharacter, required, no NA, unique within the
data set as a whole - across every pathogen and every patient, not
just within one of them. A stable identifier for this result, so
re-running the same extract later cannot create duplicate cases.
Your laboratory's own specimen or culture number rarely satisfies
this by itself: the same culture can yield more than one reported
result (a multiplex panel positive for two organisms, two isolates
from one culture with different antibiograms), and a bare specimen
number then repeats across rows. Where that is true of your source
data, build source_key yourself by combining whichever of your
own columns are unique per row - many laboratory systems need all
four of lab_number, patient_key, a test/panel code, and a
strain or isolate number to pin down one row, e.g.
paste(lab_number, patient_key, test_code, isolate_number) in your
own extract step (only lab_number and patient_key reach
EpiSODIC itself; a test code or isolate number that exists only in
your source system is fine to fold into source_key and drop
otherwise). source_key itself is never shown in the dashboard -
it exists only so a re-run stays idempotent - which is exactly why
it is safe to make it an artificial combination rather than a
real-world identifier.
lab_numberCharacter, required, no NA. Your laboratory's
own identifier for the specimen or culture this result came from
(an accession, specimen or culture number) - not pseudonymised,
and not required to be unique: two rows legitimately share one
lab_number when a single culture produced more than one reported
result (see source_key). Shown alongside patient_key in the
dashboard's case tables, since - unlike patient_key - it is not a
patient identifier and carries no reason to hide it.
patient_keyCharacter, required, no NA. A pseudonymised
patient identifier, the same for the same patient across results.
Deduplication and episode grouping key on it: without it every
positive becomes its own case. Do pseudonymise it before it
reaches EpiSODIC - a national identification number or hospital
number must not be passed through - but the pseudonym itself is
shown in the dashboard's case tables, same as lab_number.
sample_dateDate, or character in YYYY-MM-DD (ISO 8601)
form. Required, no NA. The date the sample was taken, and the
anchor every detector, trend and report is built against. If your
system falls back to a receipt date when the sample date is
unfilled, apply that fallback in your own extract step, not here.
receipt_dateDate, or character in YYYY-MM-DD form; NA
allowed. The date the laboratory received the sample. Stored for
provenance only - deliberately never used to measure reporting
delay, since a lab's receipt-date field can itself be standing in
for a missing sample date.
pathogenCharacter, required, no NA. The pathogen name
exactly as your laboratory reports it (e.g. "Escherichia coli",
"Influenza A", "Norovirus"). Free text, deliberately
unconstrained: used verbatim and never matched against a taxonomy,
since detection has to work for anything a lab can report, viral or
not. Spelling must be stable across runs - "Influenza A" and
"influenza a" are two different pathogens as far as detection is
concerned. Names matching in the default config (inst/config/episodic_default_pathogen_config.csv)
pick up that pathogen's episode length, incubation window and serial
interval; anything else falls back to the schema defaults (30-day
episode, 14 case-free days, no Rt, no MEM). The same positive may
appear under more than one pathogen value where that is
epidemiologically useful - an ETEC positive reported as both
"Escherichia coli" and "ETEC", so each is monitored separately.
care_lineCharacter; NA allowed. One of "first"
(primary care), "second" (secondary care), "third" (tertiary
care), "other", or "unknown". Anything else is
rejected. NA is read as "unknown" and stored that way, so you
need not map missing values yourself: an empty care_line, an
R NA and a database NULL all mean the same thing here, and the
dashboard shows all three as "unknown".
institution_keyCharacter, required, no NA. A stable
identifier for the reporting institution. Hashed (SHA-1) on the way
in, so a later rename does not fracture the institution's history,
and so the raw key never reaches the database.
institution_display_nameCharacter, required, no NA. The
human-readable institution name shown in the dashboard.
institution_typeCharacter, required. Exactly one of
"hospital", "ltc_institution" (long-term care),
"gp_municipality", "ooh_service" (out-of-hours service), or
"other". Anything else is rejected. This decides how the
institution is handled: "hospital" institutions
are monitored as first-class entities and are the only ones
eligible for patient-day normalisation, while a "gp_municipality"
is collapsed to its municipality.
municipalityCharacter; NA allowed. The institution's
municipality. Required in practice for "gp_municipality" rows,
since that is what they are collapsed to.
wardCharacter; NA allowed. The ward within the
institution. Only meaningful for "hospital" and
"ltc_institution" rows; leave NA otherwise. This is the unit L1
(ward-level) detection watches, so an inconsistent spelling splits
one ward into two streams.
specialismCharacter; NA allowed. The requesting clinical
specialism. Context for the epidemiologist, not a detection input.
pcCharacter; NA allowed. The patient's postcode area,
not the institution's - it is what the geography panel and
area-level (L3) detection use, in whatever form your country's
postcodes take. Store it as character, not as a number, so a
leading zero survives. With EPISODIC_GEO_DATA configured, it must
match that file's pc column exactly for a case to reach the map.
sexCharacter; NA allowed. Exactly one of "M", "F", or
"U" (unknown) when present.
Anything else is rejected, so map your own coding (1/2,
"male"/"female") in your extract step.
ageInteger; NA allowed. Age in whole years at sampling.
Not age group - the dashboard bands it itself. Use AMR::age() to
determine the age based on a patient's date of birth and the date
of specimen receipt at the lab.
Do not find out from an empty dashboard. Run episodic_check_cases()
on your extract - it needs no database, changes nothing, and reports
every problem it finds at once, with the rows and values involved and
what to do about each:
cases <- my_extract_and_transform_function() episodic_check_cases(cases)
It also reports what is merely worth a look: one pathogen spelled two
ways (two streams instead of one), no ward on any hospital row (no
ward-level detection), a patient_key that never repeats (no
deduplication), postcodes the map cannot place, sample dates in the
future. episodic_check_cases(..., stop_on_problem = TRUE) runs the same checks but throws an error,
for use in a script. episodic_run_cron() runs that before every
run, and refuses to start on data it cannot use, naming what to fix.
# A minimal but complete extract: two results, one patient, all sixteen # columns present, and the ones your laboratory does not record left NA. cases <- data.frame( source_key = c("LAB-1", "LAB-2"), lab_number = c("LAB-1", "LAB-2"), patient_key = c("PAT-1", "PAT-1"), sample_date = c("2025-01-06", "2025-01-09"), receipt_date = c("2025-01-07", "2025-01-10"), pathogen = "Norovirus", care_line = "second", institution_key = "HOSP-01", institution_display_name = "Example Hospital", institution_type = "hospital", municipality = "Groningen", ward = "Ward A2", specialism = "Internal medicine", pc = "9713", sex = c("F", "F"), age = c(71L, 71L), stringsAsFactors = FALSE ) episodic_check_cases(cases)# A minimal but complete extract: two results, one patient, all sixteen # columns present, and the ones your laboratory does not record left NA. cases <- data.frame( source_key = c("LAB-1", "LAB-2"), lab_number = c("LAB-1", "LAB-2"), patient_key = c("PAT-1", "PAT-1"), sample_date = c("2025-01-06", "2025-01-09"), receipt_date = c("2025-01-07", "2025-01-10"), pathogen = "Norovirus", care_line = "second", institution_key = "HOSP-01", institution_display_name = "Example Hospital", institution_type = "hospital", municipality = "Groningen", ward = "Ward A2", specialism = "Internal medicine", pc = "9713", sex = c("F", "F"), age = c(71L, 71L), stringsAsFactors = FALSE ) episodic_check_cases(cases)
These functions build the three time-series charts used throughout the
EpiSODIC dashboard and outbreak reports: an epidemic curve, a trend chart
against the expected baseline, and an effective reproduction number
() chart. Each takes the small, already-summarised data frame the
app itself works with, so they are also useful for reproducing a dossier's
chart in your own report or presentation. All three return a static
ggplot2::ggplot object that can be printed, saved with
ggplot2::ggsave(), or further customised with additional ggplot2
layers.
episodic_ui_epi_curve_chart( curve, lang = Sys.getenv("EPISODIC_LANGUAGE"), accent = NULL ) episodic_ui_trend_chart( trend, lang = Sys.getenv("EPISODIC_LANGUAGE"), accent = NULL ) episodic_ui_rt_chart(rt, lang = Sys.getenv("EPISODIC_LANGUAGE"), accent = NULL)episodic_ui_epi_curve_chart( curve, lang = Sys.getenv("EPISODIC_LANGUAGE"), accent = NULL ) episodic_ui_trend_chart( trend, lang = Sys.getenv("EPISODIC_LANGUAGE"), accent = NULL ) episodic_ui_rt_chart(rt, lang = Sys.getenv("EPISODIC_LANGUAGE"), accent = NULL)
curve |
A data frame with one row per day: |
lang |
Language for axis labels: |
accent |
Fill colour for the bars. Defaults to the palette's
|
trend |
A data frame with one row per week: |
rt |
A data frame with one row per estimation window: |
A ggplot2::ggplot object.
curve <- data.frame( sample_date = seq(as.Date("2025-01-01"), by = "day", length.out = 14), n_cases = c(1, 0, 2, 1, 3, 2, 4, 3, 5, 2, 1, 0, 1, 2), incomplete = c(rep(FALSE, 12), TRUE, TRUE) ) episodic_ui_epi_curve_chart(curve, lang = "en") trend <- data.frame( week_start = seq(as.Date("2025-01-06"), by = "week", length.out = 8), n_cases = c(2, 3, 1, 4, 6, 5, 3, 2), expected = c(2, 2, 2, 2, 2, 2, 2, 2), upperbound = c(4, 4, 4, 4, 4, 4, 4, 4) ) episodic_ui_trend_chart(trend, lang = "en") rt <- data.frame( window_end = seq(as.Date("2025-01-08"), by = "day", length.out = 5), mean = c(1.4, 1.3, 1.1, 0.9, 0.8), lower = c(1.0, 0.9, 0.8, 0.6, 0.5), upper = c(1.8, 1.7, 1.4, 1.2, 1.1) ) episodic_ui_rt_chart(rt)curve <- data.frame( sample_date = seq(as.Date("2025-01-01"), by = "day", length.out = 14), n_cases = c(1, 0, 2, 1, 3, 2, 4, 3, 5, 2, 1, 0, 1, 2), incomplete = c(rep(FALSE, 12), TRUE, TRUE) ) episodic_ui_epi_curve_chart(curve, lang = "en") trend <- data.frame( week_start = seq(as.Date("2025-01-06"), by = "week", length.out = 8), n_cases = c(2, 3, 1, 4, 6, 5, 3, 2), expected = c(2, 2, 2, 2, 2, 2, 2, 2), upperbound = c(4, 4, 4, 4, 4, 4, 4, 4) ) episodic_ui_trend_chart(trend, lang = "en") rt <- data.frame( window_end = seq(as.Date("2025-01-08"), by = "day", length.out = 5), mean = c(1.4, 1.3, 1.1, 0.9, 0.8), lower = c(1.0, 0.9, 0.8, 0.6, 0.5), upper = c(1.8, 1.7, 1.4, 1.2, 1.1) ) episodic_ui_rt_chart(rt)
Runs every check the episodic_case_data requirements imply over your extract and reports everything it finds in one go - what is wrong, how many rows are affected, which rows those are, what the offending values look like, and what to do about each one. Nothing is written, nothing is changed, and no database is needed: this is the call to make while you are still building your extract step, and the first call to make when a run did not produce what you expected.
episodic_check_cases(cases, stop_on_problem = FALSE)episodic_check_cases(cases, stop_on_problem = FALSE)
cases |
Your case data: a data frame or |
stop_on_problem |
A logical to indicate whether an error must be
thrown if any problem is found. Default is |
Use episodic_check_cases() plainly when you want to look, or
episodic_check_cases(..., stop_on_problem = TRUE) when you want a script
to stop. The latter is what episodic_run_cron() itself calls before a run.
None of this applies to a cluster added with episodic_add_manual_cluster():
it is never connected to your own case data, so it never goes through these
requirements (or any other check here) at all.
A data frame of findings with class episodic_case_check and
one row per finding, with columns severity ("problem" or
"advice"), issue, column, n_rows, rows, values,
message and fix. Zero rows means the data set passed every check.
A summary of what was read (rows, columns, date range, counts) is
attached as the "summary" attribute and shown when printing.
Two kinds of finding, deliberately kept apart:
Things EpiSODIC cannot work around: a missing or
unexpected column, an empty value in a column that must be filled,
a value outside a fixed set, a date that does not read as a date, a
duplicated source_key. A run refuses to start while any of these
stand.
Things that are allowed but rarely intended, and that
quietly cost you signal: no ward on any hospital row (so
ward-level detection has nothing to group on), one pathogen spelled
two ways (so its cases are split over two streams), a
patient_key that is unique per row (so deduplication and episode
grouping cannot do anything), postcodes that the geography panel
cannot place, sample dates in the future. A run proceeds
regardless; these are for you to judge.
The value prints as a report; it is also a plain data frame, one row per finding, so you can work with it programmatically (see the examples).
episodic_case_data for what each column means and which values it accepts.
cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-01-31") ) episodic_check_cases(cases) # a typical extract mistake: dates written day-first, sex as words broken <- cases broken$sample_date <- format(as.Date(broken$sample_date), "%d-%m-%Y") broken$sex <- ifelse(broken$sex == "M", "male", "female") report <- episodic_check_cases(broken) report # it is a data frame too report$column[report$severity == "problem"]cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-01-31") ) episodic_check_cases(cases) # a typical extract mistake: dates written day-first, sex as words broken <- cases broken$sample_date <- format(as.Date(broken$sample_date), "%d-%m-%Y") broken$sex <- ifelse(broken$sex == "M", "male", "female") report <- episodic_check_cases(broken) report # it is a data frame too report$column[report$severity == "problem"]
The same purpose as episodic_check_cases(), for the optional
denominators feed: every check episodic_validate_denominators()
would throw on, reported instead of thrown - what is wrong, how many
rows, which ones, and what to do about it - plus a couple of things
that are allowed but worth a look. Nothing is written, no database is
needed. Run this while building your extract step, or when a run's
positivity panels come out empty or look wrong.
episodic_check_denominators(denominators)episodic_check_denominators(denominators)
denominators |
Your positivity data: a data frame or |
A data frame of findings with class episodic_case_check, the
same shape episodic_check_cases() returns. Zero rows means the
data set passed every check.
episodic_check_cases() for the case data feed,
episodic_check_institution_activity() for the hospital activity
feed.
denom <- episodic_synthetic_denominators( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) episodic_check_denominators(denom)denom <- episodic_synthetic_denominators( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) episodic_check_denominators(denom)
The same purpose as episodic_check_cases(), for the optional
institution_activity feed: every check
episodic_run_cron() would refuse a run over, reported instead of
thrown, plus a couple of things worth a look. Nothing is written, no
database is needed.
episodic_check_institution_activity(institution_activity)episodic_check_institution_activity(institution_activity)
institution_activity |
Your hospital activity data: a data frame
or |
Unlike the case and denominator feeds, an institution_key that
matches no institution in your case data is not a problem here - it
is deliberately allowed, and only ever counted and warned about at
run time (see episodic_institution_activity_load()), since the two
feeds need not be perfectly synchronised and this function has no
case data to compare against in the first place.
A data frame of findings with class episodic_case_check, the
same shape episodic_check_cases() returns. Zero rows means the
data set passed every check.
episodic_check_cases() for the case data feed,
episodic_check_denominators() for the positivity feed.
institutions <- data.frame( institution_key = "HOSP-1", institution_type = "hospital", n_beds = 320 ) activity <- episodic_synthetic_institution_activity( institutions, start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) episodic_check_institution_activity(activity)institutions <- data.frame( institution_key = "HOSP-1", institution_type = "hospital", n_beds = 320 ) activity <- episodic_synthetic_institution_activity( institutions, start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) episodic_check_institution_activity(activity)
Bundles the fully-resolved configuration (shipped defaults, your
EPISODIC_CONFIG YAML overlay, and - if db_path points at a
database - any Settings-screen notifications override) together with
the pathogen configuration and, if set, the colour palette override,
into a single zip file. This is the Settings screen's "export
configuration" button; it is also just a plain function you can call
from the console to snapshot an instance's configuration for backup or
migration.
episodic_config_export( db_path = Sys.getenv("EPISODIC_DB", unset = NA), episodic_config_path = Sys.getenv("EPISODIC_CONFIG", unset = NA), output_dir = NULL, include_secrets = FALSE )episodic_config_export( db_path = Sys.getenv("EPISODIC_DB", unset = NA), episodic_config_path = Sys.getenv("EPISODIC_CONFIG", unset = NA), output_dir = NULL, include_secrets = FALSE )
db_path |
Path to the EpiSODIC database: an existing SQLite file,
or a MariaDB/MySQL DSN (see |
episodic_config_path |
The config path. |
output_dir |
Directory to write the zip into. Defaults to what
|
include_secrets |
If |
Invisibly, the path to the written zip file.
## Not run: episodic_config_export(db_path = Sys.getenv("EPISODIC_DB")) ## End(Not run)## Not run: episodic_config_export(db_path = Sys.getenv("EPISODIC_DB")) ## End(Not run)
Opens a connection to a database you have already set up with
episodic_db_create(), with the settings EpiSODIC needs enabled (for
SQLite: WAL journal mode, a busy timeout, and foreign key enforcement).
Remember to disconnect with DBI::dbDisconnect() when you are done.
episodic_db_connect(path, check_schema_version = TRUE)episodic_db_connect(path, check_schema_version = TRUE)
path |
Path to an existing SQLite file, or a |
check_schema_version |
Whether to refuse a database whose schema
version is not the one this build of EpiSODIC expects, with an
error naming |
An open DBI::DBIConnection.
db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) con <- episodic_db_connect(db_path) DBI::dbDisconnect(con) file.remove(db_path)db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) con <- episodic_db_connect(db_path) DBI::dbDisconnect(con) file.remove(db_path)
Run this once, when setting up a new EpiSODIC instance: it creates a new
database at path and builds all the required tables. Refuses to run
against a database that already has tables in it, so it cannot
accidentally overwrite existing surveillance data - use
episodic_db_connect() to open a database you have already set up.
episodic_db_create(path, overwrite = FALSE)episodic_db_create(path, overwrite = FALSE)
path |
Path to a SQLite file to create, or a |
overwrite |
If |
(Invisibly) an open DBI::DBIConnection to the new
database. You are responsible for disconnecting it (with
DBI::dbDisconnect()) when done.
db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) file.remove(db_path)db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) file.remove(db_path)
EpiSODIC stores its data in either a SQLite file (the default, and all
you need for a single-server deployment) or a MariaDB/MySQL database.
This function builds the connection string ("DSN") for the latter, so
you never have to hand-assemble one or worry about special characters
in your password breaking it. Use the resulting string as db_path
anywhere EpiSODIC expects one, or store it in the EPISODIC_DB
environment variable. episodic_db_dsn_mysql() is an identical alias -
use whichever name matches the server you run.
episodic_db_dsn_mariadb(host, dbname, user, password, port = 3306L) episodic_db_dsn_mysql(host, dbname, user, password, port = 3306L)episodic_db_dsn_mariadb(host, dbname, user, password, port = 3306L) episodic_db_dsn_mysql(host, dbname, user, password, port = 3306L)
host |
Server hostname or IP address. |
dbname |
Database (schema) name. |
user, password
|
Credentials. |
port |
TCP port. Defaults to |
A single string, ready to pass as db_path or to
Sys.setenv(EPISODIC_DB = ...).
episodic_db_dsn_mariadb( host = "db.internal", dbname = "episodic", user = "episodic_app", password = "s3cr3t!" )episodic_db_dsn_mariadb( host = "db.internal", dbname = "episodic", user = "episodic_app", password = "s3cr3t!" )
EpiSODIC records which version of its schema a database was built with, and refuses to open one it does not recognise rather than failing later with an unexplained SQL error (or, worse, not failing and reading a column that has since come to mean something else). When you upgrade the package and its schema has moved on, this is what brings your existing database along - in one transaction per step, so a failed migration leaves the database exactly as it was.
episodic_db_migrate( db_path = Sys.getenv("EPISODIC_DB", unset = NA), backup = TRUE )episodic_db_migrate( db_path = Sys.getenv("EPISODIC_DB", unset = NA), backup = TRUE )
db_path |
Path to an existing SQLite database, or a MariaDB/MySQL
DSN (see |
backup |
Whether to copy a SQLite database beside itself before the first step. Ignored for MariaDB/MySQL. |
You rarely need to call this yourself: episodic_run_cron() does it
at the start of every run that finds the database behind the installed
package, unless database.auto_migrate is set to false in your
configuration. Call it directly where schema changes go through change
control rather than through the application - with that setting off,
and from an account that holds the privileges to change the schema.
Nothing is ever dropped or rewritten: migrations add tables, add
columns, and backfill values. Your surveillance history, your
assessments and your audit trail are carried forward untouched. A
SQLite database is copied beside itself before the first step
(<file>.schema-<from>-to-<to>.bak); a MariaDB or MySQL database is
not, since a server's backups belong to whoever runs the server.
Each step runs inside a transaction, which under SQLite covers the
schema changes themselves. MariaDB and MySQL commit implicitly on
every CREATE/ALTER, so there a failed step can leave its schema
change in place with no version row recorded; every migration is
written to skip what it finds already done, so simply running this
again is the correct response.
Two processes can safely be asked to migrate the same database at once - a scheduled run and a manual call, or two servers sharing one MariaDB schema. One migrates; the other waits for it (up to ten minutes on MariaDB/MySQL, five seconds per step on SQLite), finds the work done, and carries on.
A database created by EpiSODIC 0.12.x or earlier carries no version at all, since the version table postdates it. Such a database is adopted at version 1 (which is the shape it already has) rather than refused.
Invisibly, the schema version the database is at afterwards.
db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) # already current: reports so and changes nothing episodic_db_migrate(db_path) file.remove(db_path)db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) # already current: reports so and changes nothing episodic_db_migrate(db_path) file.remove(db_path)
EPISODIC_DB Environment VariableLike episodic_db_connect(), but falls back to the EPISODIC_DB
environment variable when you do not pass a path explicitly - handy for
one-off console use, e.g. episodic_add_user() uses it
internally so adding an account needs only a username and
password, not a connection you build yourself first.
episodic_db_open( db_path = Sys.getenv("EPISODIC_DB", unset = NA), check_schema_version = TRUE )episodic_db_open( db_path = Sys.getenv("EPISODIC_DB", unset = NA), check_schema_version = TRUE )
db_path |
Path to an existing SQLite database, or a |
check_schema_version |
Whether to refuse a database whose schema
version is not the one this build of EpiSODIC expects, with an
error naming |
An open DBI::DBIConnection; you are responsible for disconnecting it.
db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) Sys.setenv(EPISODIC_DB = db_path) con <- episodic_db_open() DBI::dbDisconnect(con) file.remove(db_path)db_path <- tempfile(fileext = ".sqlite") con <- episodic_db_create(db_path) DBI::dbDisconnect(con) Sys.setenv(EPISODIC_DB = db_path) con <- episodic_db_open() DBI::dbDisconnect(con) file.remove(db_path)
A hard reset back to "freshly created, no data" - every row in every
EpiSODIC table is deleted, but the tables, indexes and constraints
stay exactly as episodic_db_create() built them, so the database is
immediately ready for a new episodic_run_cron() without a schema
migration. This includes episodic_app_user: dashboard accounts are
data too, and are deleted along with everything else - there is
nothing this function leaves behind to sign in with afterwards.
episodic_db_truncate(path)episodic_db_truncate(path)
path |
Path to an existing SQLite file, or a |
Deliberately hard to trigger by accident:
Refuses outright under interactive()
FALSE - a script, a cron job, or any other unattended context
can never reach the confirmation prompt, so it can never reach
the deletion either.
Prints exactly what is about to be deleted (every table, and its row count) and then requires you to type the database's own name back at a prompt. A bare yes/no is answerable on autopilot; typing the name back is not.
Invisibly, the character vector of tables that were
truncated - character(0) if you cancelled, or if the database had
no EpiSODIC tables to begin with.
## Not run: episodic_db_truncate(db_path) ## End(Not run)## Not run: episodic_db_truncate(db_path) ## End(Not run)
The fastest way to see what EpiSODIC does: this single call creates a fresh database, generates several years of synthetic laboratory data, runs detection over it, creates a demo epidemiologist account, and opens the dashboard - all without needing access to any real laboratory system or an instance configuration file. It needs nothing but the package: the shipped defaults, plus the few files it writes beside the database (see below).
episodic_demo( db_path = tempfile(fileext = ".sqlite"), username = "demo", full_name = "Demo User", email = "[email protected]", password = "demo", launch = TRUE, run_date = NULL, replay_days = 14L, lang = Sys.getenv("EPISODIC_LANGUAGE"), cases = NULL, denominators = NULL, overwrite = FALSE, ... )episodic_demo( db_path = tempfile(fileext = ".sqlite"), username = "demo", full_name = "Demo User", email = "[email protected]", password = "demo", launch = TRUE, run_date = NULL, replay_days = 14L, lang = Sys.getenv("EPISODIC_LANGUAGE"), cases = NULL, denominators = NULL, overwrite = FALSE, ... )
db_path |
Path to the SQLite database to create. Defaults to a temporary file, so repeated calls never collide and nothing is left behind once the R session ends. A database that already exists is refused, and a MariaDB/MySQL DSN is refused outright: this call generates synthetic cases and runs detection over them, which is contamination anywhere but a throwaway database. |
username, full_name, email, password
|
Credentials for the demo epidemiologist account this creates, so you can sign in and classify a cluster right away. These are placeholder values - change them for anything beyond a local demo. |
launch |
If |
run_date |
The date to run detection as of. Left That second rule is the demo's alone. Detection is bounded to a
lookback window around |
replay_days |
For the bundled synthetic data, how many of its
last days are replayed as daily runs, each loading only the cases
received by its date, after a first run over everything before them.
A scheduled instance learns its reporting delays this way, one run
at a time, and the delays are what the reporting completeness, the
nowcast and the end-of-outbreak probability are computed from: with
a single run there are none to learn from. Each replayed day adds a
few seconds; |
lang |
Dashboard language when |
cases, denominators
|
The data to generate the demo
from - normally data frames (or tibbles), passed on unchanged to
|
overwrite |
If |
... |
Arguments passed on to |
Invisibly, db_path.
EpiSODIC has no built-in geography - no default map, and no built-in
rule for turning a postcode into a province - because a default for
either would be a default for one country. The demo therefore
configures its own, exactly the way a real deployment does: two files
named after db_path (<name>-config.yaml and
<name>-pc-province.csv), plus the Netherlands postcode geometry
bundled with the package. A third, <name>-pathogens.csv, gives the
transmissible pathogens in the synthetic data an offspring
distribution (end_r, end_k), which ships empty, so the outbreak
dossier can show its end-of-outbreak probability. Its values are
illustrative, chosen for the demo, not estimates for any setting.
They are left in place, so a database built with launch = FALSE can
be re-opened later with the same geography by setting EPISODIC_DB,
EPISODIC_CONFIG, EPISODIC_PC_PROVINCE_MAP,
EPISODIC_PATHOGEN_CONFIG and EPISODIC_GEO_DATA back to them - the paths are printed when the demo finishes. Without
them the database still opens; it simply shows no map and no
provinces.
Do not find out from an empty dashboard. Run episodic_check_cases()
on your extract - it needs no database, changes nothing, and reports
every problem it finds at once, with the rows and values involved and
what to do about each:
cases <- my_extract_and_transform_function() episodic_check_cases(cases)
It also reports what is merely worth a look: one pathogen spelled two
ways (two streams instead of one), no ward on any hospital row (no
ward-level detection), a patient_key that never repeats (no
deduplication), postcodes the map cannot place, sample dates in the
future. episodic_check_cases(..., stop_on_problem = TRUE) runs the same checks but throws an error,
for use in a script. episodic_run_cron() runs that before every
run, and refuses to start on data it cannot use, naming what to fix.
episodic_check_cases() to see what EpiSODIC makes of your
own extract first, and episodic_case_data for the shape it expects.
## Not run: # launches a blocking, interactive Shiny session against several years # of freshly-generated synthetic data episodic_demo() ## End(Not run) # non-interactive: populate a database and stop there, e.g. for scripting cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) db_path <- episodic_demo(launch = FALSE, cases = cases, denominators = NULL) file.remove(db_path)## Not run: # launches a blocking, interactive Shiny session against several years # of freshly-generated synthetic data episodic_demo() ## End(Not run) # non-interactive: populate a database and stop there, e.g. for scripting cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) db_path <- episodic_demo(launch = FALSE, cases = cases, denominators = NULL) file.remove(db_path)
The dashboard can plot cluster case counts on a choropleth map by postcode
(or any other geographic unit you use), provided the optional sf
package is installed and geographic reference data is available. Without
either, the dashboard still works fine - it just falls back to a bar
chart of case counts by area instead of a map.
episodic_geo_overlay_resolve( path = Sys.getenv("EPISODIC_GEO_DATA_OVERLAY", unset = NA) ) episodic_geo_source_resolve(path = Sys.getenv("EPISODIC_GEO_DATA", unset = NA))episodic_geo_overlay_resolve( path = Sys.getenv("EPISODIC_GEO_DATA_OVERLAY", unset = NA) ) episodic_geo_source_resolve(path = Sys.getenv("EPISODIC_GEO_DATA", unset = NA))
path |
Path to an |
There is no default map. Point the EPISODIC_GEO_DATA environment
variable at your own .rds file (an sf object with a pc column
matching your case data's area codes, and a geometry column) to map
your own region. The Dutch postcode geometry bundled with the package
is used only by episodic_demo().
You can optionally add a second, purely visual layer of outlines - e.g.
province or municipality borders - drawn on top of the choropleth for
orientation, via EPISODIC_GEO_DATA_OVERLAY and
episodic_geo_overlay_resolve(). This layer carries no case counts, so
it only needs a geometry column.
An sf object, or NULL if sf is not installed, no path is
set, or the file is missing or invalid.
A default map is a default country. Falling back to the Netherlands PC4 geometry bundled with the package would give a laboratory anywhere else a silent, confident map of the Netherlands with its own case counts joined onto whichever Dutch postcodes happened to share a number - postcode-like codes are four digits in a great many countries - and a plausible-looking wrong answer is worse than no answer. So there is no default: with nothing configured the map is simply absent, and the dashboard falls back to a bar chart of case counts by area, which is correct everywhere.
episodic_demo() sets EPISODIC_GEO_DATA to the bundled Netherlands
geometry itself, so the demo still shows a map.
# NULL when unset (or when the sf package is not installed) episodic_geo_overlay_resolve(path = NA) # NULL when unset (or when the sf package is not installed) episodic_geo_source_resolve(path = NA)# NULL when unset (or when the sf package is not installed) episodic_geo_overlay_resolve(path = NA) # NULL when unset (or when the sf package is not installed) episodic_geo_source_resolve(path = NA)
Renders the body of one of EpiSODIC's emails against example data and writes it to an HTML file to open in a browser, so an email template can be checked without waiting for a detection run to send one.
episodic_mail_template_preview( kind = c("new_clusters", "report"), template_path = NULL, output_file = tempfile(fileext = ".html"), lang = Sys.getenv("EPISODIC_LANGUAGE") )episodic_mail_template_preview( kind = c("new_clusters", "report"), template_path = NULL, output_file = tempfile(fileext = ".html"), lang = Sys.getenv("EPISODIC_LANGUAGE") )
kind |
|
template_path |
Path to the |
output_file |
Where to write the HTML. |
lang |
Language to render in. Defaults to |
Two emails can be given a template of their own, each with its own
environment variable pointing at a Quarto (.qmd) file:
EPISODIC_MAIL_TEMPLATE_NEW_CLUSTERS for the alert sent when a run
detects new clusters;
EPISODIC_MAIL_TEMPLATE_REPORT for the email a scheduled outbreak
report is attached to.
A template changes the HTML body sent through the email channels
(smtp, sendmail, microsoft365); the subject line, the ntfy, Teams
and Slack messages and the run-failure email are unchanged. Two
starting points ship with the package, in
system.file("mail", package = "EpiSODIC"). Every template receives the
instance palette (episodic_palette()) and the inline styles built from
it, so it can carry the dashboard's own colours and font. See
vignette("notifications") for the data a template receives.
A template that cannot be used when an email is due never stops the email: it goes out with the built-in body, and the run log says why. This function, by contrast, stops with the error, which is what a preview is for.
The path to the written HTML file, invisibly.
## Not run: # the shipped starting point for the new-clusters alert episodic_mail_template_preview( "new_clusters", template_path = system.file( "mail", "episodic_default_mail_new_clusters.qmd", package = "EpiSODIC" ) ) ## End(Not run)## Not run: # the shipped starting point for the new-clusters alert episodic_mail_template_preview( "new_clusters", template_path = system.file( "mail", "episodic_default_mail_new_clusters.qmd", package = "EpiSODIC" ) ) ## End(Not run)
When episodic_run_cron() detects new clusters or fails, it can
notify your team through one or more channels: ntfy, SMTP, sendmail,
Microsoft 365, Teams (Power Automate Workflow webhook), or Slack.
Notifications are configured in the instance YAML configuration file
(the same file EPISODIC_CONFIG points at), under a notifications
key. The notification settings are deliberately excluded from the
detection config_hash, so changing them never alters reproducibility
and secrets never reach the config_snapshot stored on each run.
Notifications are dispatched after the detection transaction has
committed: a failed notification never rolls back a successful run.
Errors during dispatch are logged via episodic_trace() and never
stop the run from finishing.
Validates the notification configuration and sends a test message through every enabled channel. Run this interactively after setting up your instance configuration to verify that notifications work end to end.
episodic_notify_test( episodic_config_path = Sys.getenv("EPISODIC_CONFIG", unset = NA) )episodic_notify_test( episodic_config_path = Sys.getenv("EPISODIC_CONFIG", unset = NA) )
episodic_config_path |
The config path. |
Invisibly, a named logical vector: TRUE for each channel
that succeeded, FALSE for each that failed.
## Not run: # After configuring notifications in your EPISODIC_CONFIG YAML: episodic_notify_test() ## End(Not run)## Not run: # After configuring notifications in your EPISODIC_CONFIG YAML: episodic_notify_test() ## End(Not run)
Returns the colours and typography used throughout the EpiSODIC dashboard and charts, as a named list. Useful if you want to match your own plots or reports to the house style, or check what colour a given status uses.
episodic_palette()episodic_palette()
The palette ships with an organisation-neutral default
(inst/config/episodic_default_style.yaml). To use your own institute's colours or fonts instead,
point the EPISODIC_STYLE environment variable at a YAML file
that overrides only the roles you want to change - anything you do not set
keeps its shipped default.
The same palette governs everything EpiSODIC draws: the dashboard, the
outbreak reports rendered by episodic_report_render() (their charts,
text, headings, links and tables) and the emails it sends. The process
doing the render resolves it and hands it to the template, so a report
or an email always carries the colours and font of the instance that
produced it.
Each value in the EPISODIC_STYLE file is checked: a colour must be a
quoted hex colour ("#RGB", "#RRGGBB" or "#RRGGBBAA"), font a CSS
font-family stack and font_size_base a CSS length (px, pt, rem
or em). A value that fails keeps the shipped value for that role only,
and a file that does not exist or cannot be read keeps the whole shipped
palette. Neither stops the dashboard, a report or an email: the problem
is shown on the Info screen's reference-data panel, written to every
detection run's log, and raised once as a warning.
This is independent of episodic_config_resolve()
on purpose: colours and typography never affect the config_hash recorded
with a detection run, since they have no bearing on reproducibility.
A named list. The greyscale neutrals are
ink (default text), muted (secondary text), faint (tertiary text),
border, bg_subtle, bg, and surface. The semantic roles are
primary, secondary, tertiary, success, warning, and danger,
each with _dark/_light/_tint variants where used. The severity
scale is severity_baseline, severity_low, severity_medium,
severity_high and severity_very_high. font and
font_size_base hold the app's typography, not a colour.
These are all the default values, and all can be changed using a custom YAML file.
font: '"IBM Plex Sans", ui-sans-serif, system-ui, "Segoe UI", sans-serif' font_size_base: "13px" ink: "#222222" muted: "#495057" faint: "#ADB5BD" border: "#DEE2E6" bg_subtle: "#EBEBEB" bg: "#F8F9FA" surface: "#FFFFFF" primary: "#008CBA" primary_dark: "#005F7A" primary_light: "#66BAD6" primary_tint: "#D9EDF5" secondary: "#333333" secondary_dark: "#1A1A1A" tertiary: "#20C997" tertiary_dark: "#168D6A" success: "#43AC6A" success_dark: "#2F784A" warning: "#F8AC59" warning_dark: "#AE783E" danger: "#F36A5A" danger_dark: "#AA4A3F" severity_baseline: "#ADB5BD" severity_low: "#43AC6A" severity_medium: "#E99002" severity_high: "#F04124" severity_very_high: "#222222"
Of note:
primary_dark is the background colour of the navigation bar.
severity_baseline to severity_very_high are one ordinal scale, shared by an epidemiologist's verdicts and the Moving Epidemic Method's intensity bands, so a step has one colour on every screen. Text on them is set to whichever of ink, white or black reads.
font is a CSS font-family stack, and font_size_base is the app's base font size.
Every other font size in the dashboard is set in rem relative to it, so changing font_size_base scales the whole app's type proportionally (useful when swapping in a font that reads naturally smaller or larger than the default at the same pixel size).
Changing font only changes the CSS declaration; if it names a webfont rather than a system font, delivering that font (a self-hosted @font-face or a link to its provider) is the operator's own concern.
pal <- episodic_palette() pal$primary pal$danger pal$font pal$font_size_basepal <- episodic_palette() pal$primary pal$danger pal$font pal$font_size_base
Produces a self-contained HTML outbreak report for one cluster - the document you send to a treating physician, an infection prevention nurse, or a clinical colleague (e.g., a clinical microbiologist) who has neither an EpiSODIC account nor R installed. The report includes the epidemic curve, trend chart, the narrative summary shown in the dashboard, and (optionally) the case line list.
episodic_report_render( con, cluster_id, output_dir, user_id = NA, include_linelist = TRUE, small_count_threshold = NULL, episodic_config_path = Sys.getenv("EPISODIC_CONFIG", unset = NA), lang = Sys.getenv("EPISODIC_LANGUAGE"), qmd_path = Sys.getenv("EPISODIC_QUARTO_REPORT", unset = NA) )episodic_report_render( con, cluster_id, output_dir, user_id = NA, include_linelist = TRUE, small_count_threshold = NULL, episodic_config_path = Sys.getenv("EPISODIC_CONFIG", unset = NA), lang = Sys.getenv("EPISODIC_LANGUAGE"), qmd_path = Sys.getenv("EPISODIC_QUARTO_REPORT", unset = NA) )
con |
|
cluster_id |
The cluster to report on. |
output_dir |
Directory the rendered HTML is written to. Created if it does not exist. |
user_id |
The id of the user requesting the report, or |
include_linelist |
If |
small_count_threshold |
Small counts in the geography/institution
breakdown tables are suppressed (shown as |
episodic_config_path |
The config path. |
lang |
Report language: |
qmd_path |
Path to the Quarto template to render. Defaults to the
|
Every render is kept, versioned, and logged to the database, including exactly which cases it contained - so what was sent out on a given date stays a fully recoverable record, even if the underlying data changes later.
By default the report uses EpiSODIC's own report template. If your
organisation needs its own layout or branding, set the
EPISODIC_QUARTO_REPORT environment variable to your own .qmd file;
the default inst/report/episodic_default_report.qmd
is a good starting point to copy and adapt.
The report is styled from the instance palette (episodic_palette()),
the same colours and font as the dashboard: its charts are drawn in it,
and an episodic.scss theme built from it is written beside the
template, which the shipped template names as its theme. A custom
template receives the palette as palette in its data and can name the
same theme file.
Rendering requires Quarto to be installed
separately (both the quarto R package and the Quarto command-line
tool) - this function raises an informative error if it is not found.
Invisibly, a list with file_path (where the HTML was written),
file_sha256, version_no, and report_id.
## Not run: # needs both the quarto R package and the Quarto CLI installed, plus a # database with at least one detected cluster - see episodic_demo() for # a populated one, and the app's rail for a cluster_id to render db_path <- episodic_demo(launch = FALSE) con <- episodic_db_connect(db_path) episodic_report_render(con, cluster_id = 1, output_dir = tempdir()) DBI::dbDisconnect(con) ## End(Not run)## Not run: # needs both the quarto R package and the Quarto CLI installed, plus a # database with at least one detected cluster - see episodic_demo() for # a populated one, and the app's rail for a cluster_id to render db_path <- episodic_demo(launch = FALSE) con <- episodic_db_connect(db_path) episodic_report_render(con, cluster_id = 1, output_dir = tempdir()) DBI::dbDisconnect(con) ## End(Not run)
Launches the Shiny dashboard against a database already populated by
episodic_run_cron(): an overview of monitored surveillance streams and
each cluster's dossier, with charts, its narrative interpretation, and
an assessment form. Anyone can browse the dashboard; signing in is only
required to record an assessment or classify a cluster.
episodic_run_app( db_path = Sys.getenv("EPISODIC_DB", unset = NA), lang = Sys.getenv("EPISODIC_LANGUAGE"), ... )episodic_run_app( db_path = Sys.getenv("EPISODIC_DB", unset = NA), lang = Sys.getenv("EPISODIC_LANGUAGE"), ... )
db_path |
Path to the EpiSODIC database: a SQLite file, or a
MariaDB/MySQL DSN (see |
lang |
Dashboard language, fixed for the whole running app - there
is no in-app language switcher. One of |
... |
Passed on to |
If you just want to explore EpiSODIC without setting anything up first,
use episodic_demo() instead - it populates a database with synthetic
data and calls this function for you.
Invisible; called for its side effect of starting the app. This call blocks until the app is stopped.
## Not run: # opens a blocking, interactive dashboard session; see episodic_demo() # for a one-call version that also creates a populated demo database episodic_run_app("/path/to/episodic.sqlite") ## End(Not run)## Not run: # opens a blocking, interactive dashboard session; see episodic_demo() # for a one-call version that also creates a populated demo database episodic_run_app("/path/to/episodic.sqlite") ## End(Not run)
This is the function you schedule to run regularly (e.g. daily, via cron): it pulls in new laboratory data, checks every monitored stream for statistical aberrations with the configured detectors, reconciles the results into cluster dossiers for the board to assess, and records everything in the database. A run either completes in full or leaves no trace at all - it runs inside a single database transaction, so a failed run is always safe to simply retry.
episodic_run_cron( cases, denominators = NULL, institution_activity = NULL, episodic_config_path = Sys.getenv("EPISODIC_CONFIG", unset = NA), pathogen_config_path = Sys.getenv("EPISODIC_PATHOGEN_CONFIG", unset = NA), db_path = Sys.getenv("EPISODIC_DB"), host = Sys.info()[["nodename"]], account = Sys.info()[["user"]], run_date = Sys.Date(), backfill = NULL, debug = FALSE )episodic_run_cron( cases, denominators = NULL, institution_activity = NULL, episodic_config_path = Sys.getenv("EPISODIC_CONFIG", unset = NA), pathogen_config_path = Sys.getenv("EPISODIC_PATHOGEN_CONFIG", unset = NA), db_path = Sys.getenv("EPISODIC_DB"), host = Sys.info()[["nodename"]], account = Sys.info()[["user"]], run_date = Sys.Date(), backfill = NULL, debug = FALSE )
cases |
Your laboratory data: a data frame or |
denominators |
Optional: your testing-volume data, in the same
form as |
institution_activity |
Optional: your hospital patient-days
data (see |
episodic_config_path |
The config path. |
pathogen_config_path |
Path to an operator's own per-pathogen CSV,
overlaid row-by-row on the shipped
|
db_path |
Path to the EpiSODIC database: a SQLite file (created
automatically if it does not exist yet) or a MariaDB/MySQL DSN (see
|
host, account
|
Recorded with the run for audit purposes; default to the current machine and account. |
run_date |
The date to treat as "today". Defaults to the system
date. Every detector anchors on it: |
backfill |
Whether this run reports the whole case history it is
given rather than only what falls inside the detectors' lookback
windows. |
debug |
If |
EpiSODIC never connects to your laboratory system directly. You extract
and transform your own data beforehand, and hand it over as a plain data
frame or tibble: cases for the laboratory results themselves (see
episodic_case_data for the required columns and their allowed
values), and optionally denominators and institution_activity for
testing volume and hospital activity. A data set is the normal case; if
producing the data only makes sense at run time (a live database query,
for instance), a zero-argument function returning one is accepted just
as well.
The exact detection settings used are recorded with the run, so any past result can always be traced back to the configuration that produced it.
A database whose schema is behind the installed package is brought up
to date before the run starts, the way episodic_db_migrate() would,
so upgrading an instance is upgrading the package. Set
database.auto_migrate to false in your configuration where schema
changes go through a DBA instead; the run then refuses such a database
and records why. See vignette("deployment"), "Upgrading EpiSODIC".
So is what each feed delivered. Before the run writes anything, your
case data goes through episodic_check_cases(). Structural problems -
a missing column, a value outside the allowed set, a date that does not
read as a date - stop the run with an error naming every offending
column, its values and the rows they are in, and are recorded on the
run as well, so the reason is visible both where the run was started
and in the dashboard's activity screen. Advisory findings are mentioned
once and the run proceeds. Run episodic_check_cases() on your extract
yourself to see all of it without starting a run at all. A run that
fails later, for any other reason, records the reason and warns rather
than returning quietly. Rows
that are merely unmatched are counted rather than dropped in silence:
institution activity whose institution_key matches no known
institution is skipped with a warning, its count recorded, and the run
finishes "partial" instead of "success". Both are complete runs the
dashboard reads from; "partial" says go and look at why rows were
skipped. episodic_detection_run carries the counts (n_cases_supplied,
n_cases_inserted, n_activity_skipped, and the rest).
Invisibly, the run_id of the completed run. The run's row in
episodic_detection_run holds its status, the per-feed load counts,
and error_text if it failed. Case data that does not satisfy the
requirements throws instead of returning - the run row is still written,
with status = "failed" and the same message in error_text.
Do not find out from an empty dashboard. Run episodic_check_cases()
on your extract - it needs no database, changes nothing, and reports
every problem it finds at once, with the rows and values involved and
what to do about each:
cases <- my_extract_and_transform_function() episodic_check_cases(cases)
It also reports what is merely worth a look: one pathogen spelled two
ways (two streams instead of one), no ward on any hospital row (no
ward-level detection), a patient_key that never repeats (no
deduplication), postcodes the map cannot place, sample dates in the
future. episodic_check_cases(..., stop_on_problem = TRUE) runs the same checks but throws an error,
for use in a script. episodic_run_cron() runs that before every
run, and refuses to start on data it cannot use, naming what to fix.
episodic_check_cases() to see what EpiSODIC makes of your
extract before you schedule anything, and episodic_case_data for
the requirements it checks against.
db_path <- tempfile(fileext = ".sqlite") cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) run_id <- episodic_run_cron(db_path = db_path, cases = cases) file.remove(db_path)db_path <- tempfile(fileext = ".sqlite") cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) run_id <- episodic_run_cron(db_path = db_path, cases = cases) file.remove(db_path)
A scheduled report is a standing subscription on one cluster: every
interval_days, episodic_run_cron() emails the outbreak report -
the same self-contained HTML episodic_report_render() produces on
demand - to a list of addresses, as an attachment. It exists for
people who need to follow a cluster without an EpiSODIC account, or
with only a viewer one: a ward manager, a department head, a
referring physician. This is deliberately not the same thing as
episodic_notifications: those alert epidemiologists that
something needs assessing; a scheduled report keeps everyone else
informed once it is being watched, on a cadence they chose, without
needing to open the dashboard at all.
An epidemiologist sets a schedule from the cluster's Reports panel:
an interval in days, one or more recipient addresses, which
already-configured, email-capable notification channel to send
through (smtp, sendmail or microsoft365 - see
episodic_notifications), and whether to include the case line list
(excluded by default, since these recipients are often outside the
organisation - see vignette("deployment") for how a custom Quarto
template can include it anyway). Every setting is stored as an
event, the same event-sourced shape as a cluster note: setting a new
schedule (even just changing the recipient list) starts its cadence
over, and the very next cron run sends the first report under the new
settings so the change is confirmed immediately rather than silently
taking effect days later.
Each cron run checks every cluster's current schedule and sends
whichever are due - due the first time under a schedule's current
settings, or interval_days after the last successful send under
those settings. A failed send (a broken SMTP relay, say) does not
postpone the next attempt: the very next cron run tries again, and
every attempt, sent or failed, is logged to
episodic_report_subscription_send. A schedule stops automatically,
after one final report, the run a cluster closes, merges, or is
suppressed - further updates about an inactive cluster would be noise
rather than news. An epidemiologist can also cancel a schedule
outright at any time.
Runs an interactive Azure AD login and caches the refresh token for
subsequent unattended use by episodic_run_cron(). You only need to
run this once per server, unless the token expires. Requires the
Microsoft365R and AzureGraph packages.
episodic_setup_microsoft365( tenant_id, client_id = NULL, scopes = c("Mail.Send", "User.Read", "openid", "offline_access") )episodic_setup_microsoft365( tenant_id, client_id = NULL, scopes = c("Mail.Send", "User.Read", "openid", "offline_access") )
tenant_id |
Your Azure AD tenant ID. |
client_id |
Your Azure AD app registration's client ID. If
|
scopes |
Scopes to request. The default requests |
If your Azure AD app registration has a client secret configured for
application-level (daemon) access, put the client_secret in the
instance YAML instead and skip this function entirely: the cron will
use the client-credentials flow directly.
If your staff already authenticate to Microsoft 365 through a shared
login on this server (for example by having called
Microsoft365R::get_business_outlook() interactively at some point, or
by running this function once), you can skip client_id entirely in
the instance YAML: episodic_notify_microsoft365() will reuse whatever
cached Azure AD token it finds for the configured tenant_id in
AzureAuth::AzureR_dir(), without any client ID, client secret, or new
interactive login.
Invisible TRUE on success.
## Not run: episodic_setup_microsoft365(tenant_id = "your-tenant-id") ## End(Not run)## Not run: episodic_setup_microsoft365(tenant_id = "your-tenant-id") ## End(Not run)
Produces several years of laboratory surveillance data for a fictional
northern-Netherlands region - eight hospitals, twenty long-term care
institutions and the region's municipalities - and injects six
outbreaks for the detectors to find. This is what powers
episodic_demo() and the package's test suite, and it doubles as a
worked example of the shape your own data should have (see
episodic_case_data).
episodic_synthetic_cases( start_date = end_date - 5 * 365, end_date = Sys.Date(), seed = 1, outbreaks = TRUE, outbreak_offsets = NULL )episodic_synthetic_cases( start_date = end_date - 5 * 365, end_date = Sys.Date(), seed = 1, outbreaks = TRUE, outbreak_offsets = NULL )
start_date |
First sample date to generate. Defaults to five years
before |
end_date |
Last sample date to generate. Defaults to today, so a demo built from this always shows current surveillance rather than whatever year the package was released in. |
seed |
RNG seed, for reproducible demo data. |
outbreaks |
Which outbreaks to inject: |
outbreak_offsets |
Days to move outbreaks further back from
|
A data frame satisfying episodic_check_cases(), carrying the
ground truth of what was injected as an attribute - see
episodic_synthetic_ground_truth().
A seasonal Poisson baseline for eight endemic pathogens, deliberately thin per place: a demo whose baseline keeps tripping the rule-based detectors by coincidence buries the outbreaks it is meant to demonstrate. Influenza A and RSV come in short winter waves of varying size and timing, as they do in life, so the Moving Epidemic Method has seasons to fit its thresholds and intensity bands to; the others rise and fall gently through the year. Patients recur - roughly one case in six is a repeat positive from a patient already in the data - so deduplication and episode grouping have something to do.
On top of that, six outbreaks, sized from a single case to a regional wave, each shaped for a different detector:
| Outbreak | Shape | Found by |
| Invasive meningococcal disease | one case | rare_trigger |
| Ward cluster | 3 cases, one ward, 12 days | same_place |
| Nursing home outbreak | 8 cases, one institution, 9 days | same_place |
| Point source | 14 cases, one ward, days apart | same_place |
| Propagated | 15 cases, four generations, 90 days | same_place, Rt |
| Regional wave | 144 cases, diffuse, 6 rising weeks | Farrington, MEM |
The regional wave is deliberately spread one case to a place: no institution sees enough of it for a rule-based detector to notice, and only the statistical baseline comparison finds it. That contrast - a diffuse signal no amount of local vigilance would catch - is half the reason the statistical detectors exist.
Outbreaks are anchored to end_date and clipped to the window you ask
for, so a short window returns a partial one rather than cases outside
the range you asked for.
The result carries its own ground truth: which outbreaks were injected,
where and when each of them ran, and exactly which cases belong to
which. Read it with episodic_synthetic_ground_truth(). Injected cases
also carry a PT-OUTBREAK-* patient key, but that is a convenience for
a human reading a line list and not an interface: anything measuring
detection against what was injected takes the ground truth, never a
pattern match on a key.
episodic_synthetic_ground_truth() for what was injected,
episodic_check_cases() to see what the requirements make of it,
and episodic_synthetic_cases_calibration() for many more clusters
than a demo wants, to tune a configuration against.
cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) nrow(cases) head(cases)cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) nrow(cases) head(cases)
episodic_synthetic_cases() injects six outbreaks in total - enough to
show every detector working, and few enough that a demo dashboard reads
like a real morning's work. That is the wrong shape for tuning a
configuration against (e.g. deciding how many dossiers your board can
realistically review per month). This function fills that gap: on top
of the same baseline and the same six outbreaks, it adds many
independent case clusters for one chosen pathogen, at a rate you
control, so you can see how detection volume responds as you adjust
n_bumps_per_month or your own configuration. Every case it adds
carries a PT-VOL-* patient key so it is always identifiable as
synthetic tuning data, never mistaken for anything else.
episodic_synthetic_cases_calibration( start_date = end_date - 5 * 365, end_date = Sys.Date(), pathogen = "Clostridioides difficile", n_bumps_per_month = 3, seed = 1 )episodic_synthetic_cases_calibration( start_date = end_date - 5 * 365, end_date = Sys.Date(), pathogen = "Clostridioides difficile", n_bumps_per_month = 3, seed = 1 )
start_date, end_date
|
The window to generate over; defaults as in
|
pathogen |
Which pathogen to generate the extra clusters for. Defaults to Clostridioides difficile, a plausible example of an endemic pathogen that produces frequent clusters at a busy institution. |
n_bumps_per_month |
Average number of independent case clusters generated per calendar month. Raise or lower this to see how detection volume responds. |
seed |
RNG seed, for reproducible runs. |
A data frame satisfying episodic_check_cases(),
including everything episodic_synthetic_cases() produces
(background baseline, the six demo outbreaks) plus the extra volume.
cases <- episodic_synthetic_cases_calibration( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-06-30"), n_bumps_per_month = 4 ) sum(startsWith(cases$patient_key, "PT-VOL-"))cases <- episodic_synthetic_cases_calibration( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-06-30"), n_bumps_per_month = 4 ) sum(startsWith(cases$patient_key, "PT-VOL-"))
Case counts alone cannot distinguish a rise in infections from a rise in testing. If you can supply how many tests were performed - even as a weekly aggregate, not per-test detail - EpiSODIC can show a positivity rate alongside the case count, which is often the more meaningful signal. This feed is entirely optional: skip it and positivity panels simply stay blank.
episodic_synthetic_denominators( start_date = end_date - 5 * 365, end_date = Sys.Date(), seed = 1 )episodic_synthetic_denominators( start_date = end_date - 5 * 365, end_date = Sys.Date(), seed = 1 )
start_date, end_date
|
The period to generate weekly rows for.
Defaults to the five years up to today, matching
|
seed |
RNG seed, for reproducible demo data. |
This function is a synthetic example showing the expected shape: weekly
counts of a multiplex GI PCR panel that also reports Norovirus. Use it as
a template for your own data, which you pass to episodic_run_cron() as
denominators - a data frame or tibble with the same five
columns: pathogen, sample_date (week start), care_line,
area_code (may be NA), and n_tests.
A data frame with pathogen, sample_date (week start),
care_line, area_code, n_tests.
denom <- episodic_synthetic_denominators( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) head(denom)denom <- episodic_synthetic_denominators( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) head(denom)
episodic_synthetic_cases() injects outbreaks into an otherwise
endemic history. This reads back exactly what it injected: one row per
outbreak saying where and when it ran and how large it was, and the
case-level membership that goes with it. It is what makes a detection
result measurable against known truth rather than against an
epidemiologist's verdict on it - see episodic_validate_detection().
episodic_synthetic_ground_truth(cases)episodic_synthetic_ground_truth(cases)
cases |
A data frame from |
Injected cases also carry a recognisable PT-OUTBREAK-* patient key,
and it is tempting to recover membership by matching on it. Do not:
that couples whatever does the matching to a naming convention nobody
has undertaken to keep, and it fails silently, as a smaller set of
outbreak cases rather than an error, the first time the convention
changes.
A list of two data frames:
outbreaksOne row per injected outbreak: outbreak_id,
label, pathogen, institution_key and ward (both NA
where the outbreak was not confined to one), first_day,
peak_day, last_day, n_cases, and the expected_channel
and expected_level the shape was designed to exercise.
casesOne row per injected case: outbreak_id and the
source_key it carries in the case data, which is what joins it
to episodic_case once a run has loaded it.
Both are empty (zero rows, same columns) when nothing was injected.
episodic_synthetic_cases(), episodic_validate_detection()
cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) truth <- episodic_synthetic_ground_truth(cases) truth$outbreaks[, c("outbreak_id", "pathogen", "n_cases")]cases <- episodic_synthetic_cases( start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) truth <- episodic_synthetic_ground_truth(cases) truth$outbreaks[, c("outbreak_id", "pathogen", "n_cases")]
Raw case counts at a hospital can rise simply because the hospital is busier, not because infection risk has increased. If you can supply weekly patient-days (or admissions, or bed counts) per hospital, EpiSODIC can normalise case counts against activity for a more reliable signal. This feed is entirely optional: without it, detection falls back to raw counts, which is a reasonable default, not a broken one.
episodic_synthetic_institution_activity( institutions = episodic_synthetic_institutions(), start_date = end_date - 5 * 365, end_date = Sys.Date(), seed = 1 )episodic_synthetic_institution_activity( institutions = episodic_synthetic_institutions(), start_date = end_date - 5 * 365, end_date = Sys.Date(), seed = 1 )
institutions |
A data frame (or tibble) of institutions from
your own registry, with
|
start_date, end_date
|
The period to generate weekly rows for.
Defaults to the five years up to today, matching
|
seed |
RNG seed, for reproducible demo data. |
This function is a synthetic example showing the expected shape: weekly
patient-days per hospital, modelled as bed count times occupancy, with a
realistic winter peak. Use it as a template for your own data, which you
pass to episodic_run_cron() as institution_activity -
normally a data frame or tibble.
A data frame with institution_key, period_start,
period_end, patient_days, n_beds, source.
institutions <- data.frame( institution_key = "HOSP-1", institution_type = "hospital", n_beds = 320 ) activity <- episodic_synthetic_institution_activity( institutions, start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) head(activity)institutions <- data.frame( institution_key = "HOSP-1", institution_type = "hospital", n_beds = 320 ) activity <- episodic_synthetic_institution_activity( institutions, start_date = as.Date("2025-01-01"), end_date = as.Date("2025-03-31") ) head(activity)
Small HTML formatting helpers used when building dossier text, both in
the dashboard and in the Quarto outbreak report template. Both are exported so that a custom
report template of your own (set via EPISODIC_QUARTO_REPORT) can use
the same formatting as the shipped one. Input text is always HTML-escaped
first, so the result is safe to pass on to shiny::HTML().
episodic_ui_italicise_taxon(pathogen) episodic_ui_code_join(detectors, sep = ", ")episodic_ui_italicise_taxon(pathogen) episodic_ui_code_join(detectors, sep = ", ")
pathogen |
A character vector of pathogen display names. |
detectors |
A character vector of detector names. |
sep |
Separator between entries. |
episodic_ui_italicise_taxon() italicises pathogen names that AMR
recognises as a taxonomic binomial (e.g. Escherichia coli), following
standard microbiological convention. Names it does not recognise (e.g.
"Influenza A", a virus type rather than a species) are left as-is.
episodic_ui_code_join() renders a vector of detector names (e.g.
"farrington", "same_place") as inline <code>, joined into one
string - useful when listing which detectors flagged a cluster.
A character vector (or, for episodic_ui_code_join(), a single
string), safe to pass to shiny::HTML().
episodic_ui_italicise_taxon(c("Escherichia coli", "Influenza A")) episodic_ui_code_join(c("farrington", "same_place"))episodic_ui_italicise_taxon(c("Escherichia coli", "Influenza A")) episodic_ui_code_join(c("farrington", "same_place"))
A sensitivity of 0.8 and one false alarm per two thousand
stream-weeks mean very little on their own. They mean a great deal
next to what a rule anyone could write in an afternoon gets on the
same data. This runs such a rule over exactly the same generated
history, week by week, and reports it through the same matching and
the same metrics as episodic_validate_detection(), so the two sit in
one table.
episodic_validate_comparator( method = c("same_place", "shewhart"), seeds = 1, end_date = Sys.Date(), history_years = 1, evaluation_weeks = 4, min_recall = 0.5, min_precision = 0.5, outbreaks = TRUE, outbreak_offsets = NULL, n_cases = 3, k_days = 7, case_free_days = 14, baseline_weeks = 52, sd_multiplier = 2, quiet = TRUE )episodic_validate_comparator( method = c("same_place", "shewhart"), seeds = 1, end_date = Sys.Date(), history_years = 1, evaluation_weeks = 4, min_recall = 0.5, min_precision = 0.5, outbreaks = TRUE, outbreak_offsets = NULL, n_cases = 3, k_days = 7, case_free_days = 14, baseline_weeks = 52, sd_multiplier = 2, quiet = TRUE )
method |
Which rule to run. |
seeds |
RNG seeds to replicate over. Seeds are the replicates: one realisation of a stochastic generator is an anecdote, so the summary reports medians and interquartile ranges across them. |
end_date |
The last day of generated history, and the last run the replay makes. Defaults to today, which means two runs on different days are not comparable; pin it for anything whose numbers are going to be quoted. |
history_years |
Years of endemic history before the evaluation window, for the detectors to build a baseline from. |
evaluation_weeks |
Weekly runs to replay. |
min_recall, min_precision
|
The matching thresholds described above. |
outbreaks, outbreak_offsets
|
Passed to
|
n_cases, k_days, case_free_days
|
The |
baseline_weeks, sd_multiplier
|
The |
quiet |
If |
Two rules are available:
"same_place"Any n_cases cases of one pathogen at one
place within k_days. A place is a ward inside a hospital and the
institution itself everywhere else, which is what
episodic_detect_same_place() watches. Cases at one place more
than case_free_days apart belong to separate alarms.
"shewhart"A plain control limit on weekly counts: the
whole catchment's count for one pathogen in the week just
completed, against the mean plus sd_multiplier standard
deviations of the baseline_weeks weeks before it. Consecutive
alarming weeks are one alarm, not one each.
Neither reconciles, scores, suppresses or ages anything, which is the
point: they are the floor a four-detector design has to clear to be
worth its complexity. Their false-alarm denominator is their own -
places watched, or pathogens watched - and not EpiSODIC's stream
count, so read denominator before comparing two rates.
An episodic_validation object, in the same shape
episodic_validate_detection() returns, with meta$method naming
the rule and meta$config_hash absent - a rule this simple has no
configuration to hash.
naive <- episodic_validate_comparator() naive$summary[naive$summary$metric == "sensitivity", ]naive <- episodic_validate_comparator() naive$summary[naive$summary$metric == "sensitivity", ]
The dashboard's Performance screen measures this instance against its own epidemiologists' verdicts. That is the right operational metric and a circular one for a paper: it reports what the board thought of what the system showed them, and an outbreak the detectors miss never becomes a cluster for anyone to have an opinion about, so sensitivity cannot be measured that way at all.
episodic_validate_detection( seeds = 1, end_date = Sys.Date(), history_years = 1, evaluation_weeks = 4, min_recall = 0.5, min_precision = 0.5, outbreaks = TRUE, outbreak_offsets = NULL, detectors = episodic_validation_detectors(), config = NULL, pathogen_config = NULL, quiet = TRUE ) ## S3 method for class 'episodic_validation' print(x, ...)episodic_validate_detection( seeds = 1, end_date = Sys.Date(), history_years = 1, evaluation_weeks = 4, min_recall = 0.5, min_precision = 0.5, outbreaks = TRUE, outbreak_offsets = NULL, detectors = episodic_validation_detectors(), config = NULL, pathogen_config = NULL, quiet = TRUE ) ## S3 method for class 'episodic_validation' print(x, ...)
seeds |
RNG seeds to replicate over. Seeds are the replicates: one realisation of a stochastic generator is an anecdote, so the summary reports medians and interquartile ranges across them. |
end_date |
The last day of generated history, and the last run the replay makes. Defaults to today, which means two runs on different days are not comparable; pin it for anything whose numbers are going to be quoted. |
history_years |
Years of endemic history before the evaluation window, for the detectors to build a baseline from. |
evaluation_weeks |
Weekly runs to replay. |
min_recall, min_precision
|
The matching thresholds described above. |
outbreaks, outbreak_offsets
|
Passed to
|
detectors |
Which detectors to switch on, for a drop-one
analysis. Any of |
config |
Further instance configuration to write, as a nested
list (e.g. |
pathogen_config |
Optional per-pathogen overrides: a data frame
(or a path to a CSV) with a |
quiet |
If |
x |
An |
... |
Ignored. |
This measures the detectors against outbreaks that are known to be
there because they were put there. It generates synthetic history with
episodic_synthetic_cases(), replays it week by week through
episodic_run_cron() against a throwaway database, and compares the
clusters that came out against the ground truth that went in.
An episodic_validation object: a list of
outbreaksOne row per (seed, seeded outbreak): whether it was detected, by which cluster and which detector, how much of it that cluster held, how late (three ways - see below), and how much of it was still to come at that moment.
clustersOne row per (seed, cluster raised): its case-set precision, whether it counts as a true positive, its priority score, and which detectors fired on it.
runsOne row per (seed, run): streams watched, clusters raised, and how many of those survived lattice suppression.
overlapEvery (run, cluster, outbreak) that shared a case, which is the raw material both tables above are derived from.
truthWhat was injected: the outbreak table and the
case-level membership, per seed. Kept with the result so the
matching thresholds can be varied afterwards
(episodic_validate_rethreshold()) without replaying anything.
time_to_detectionThe Kaplan-Meier curve of days to detection over the outbreaks that began inside the evaluation window, with the ones nothing found kept in as right-censored observations rather than dropped.
summaryThe headline numbers, one row per metric and group.
metaThe package version, the resolved config_hash, the
thresholds, the seeds, and the denominators every figure rests
on.
On the cases themselves, in both directions, never on overlapping dates. An endemic winter cluster spanning the same fortnight as a seeded outbreak overlaps it perfectly in time and shares not one case with it.
An outbreak is detected when some cluster's final membership
holds at least min_recall of the outbreak's cases.
A cluster is a true positive when at least min_precision of
its own cases belong to one seeded outbreak. Anything else is a
false alarm.
Both thresholds are arguments because both are choices, and a result
that only holds at one particular pair of values is not a result.
Sweep them and see (episodic_validate_rethreshold() does it without
replaying anything; data-raw/validation/ does it over a grid).
The day something first appeared on the board that turned out to be
this outbreak (delay_from_open), the day what was on the board was
mostly this outbreak (delay_from_first), and the day it held most of
the whole outbreak, cases still to come included, are three different
claims. They can be days apart. All three are reported, because
picking one and calling it "the delay" would be choosing a number
rather than measuring one.
A single run over the whole history says nothing about timeliness: it
sees the outbreak's last case at the same moment as its first. So the
replay steps run_date forward one week at a time and hands each run
only the cases sampled on or before that date - what a laboratory
extract taken that day would actually have contained.
Two consequences worth knowing before reading any number out of this.
Farrington needs (farrington$b + 1) * 52 weeks of history and MEM
needs two seasons, so an evaluation window that starts before the
baseline requirement is met measures the warm-up rather than the
method: keep history_years at 4 or more if the statistical detectors
are meant to be running. And all six seeded outbreaks are anchored to
the end of the generated window unless outbreak_offsets disperses
them, so a prospective evaluation wants them dispersed.
Every run refits Farrington for every eligible stream, so the cost is
roughly (seeds x weeks) full detection runs. The defaults are
deliberately small - one seed, a short window, a single year of
history - so this returns in seconds and can be run from an example.
They are a demonstration of the machinery, not a study: at one year of
history the statistical detectors have no baseline to fit against and
report nothing, and one seed is one realisation. The study lives in
data-raw/validation/, is measured in minutes to hours, and is not
run by the test suite, by R CMD check, or by anything a user
installs.
episodic_synthetic_ground_truth() for what is being
measured against, and episodic_run_cron() for the detection run
this replays.
result <- episodic_validate_detection() result$summary[result$summary$metric == "sensitivity", ]result <- episodic_validate_detection() result$summary[result$summary$metric == "sensitivity", ]
min_recall and min_precision are choices, and a result that only
holds at one particular pair of them is not a result. This applies a
different pair to a replay that has already been run, so a sweep costs
arithmetic rather than another few hours of detection.
episodic_validate_rethreshold( result, min_recall = result$meta$min_recall, min_precision = result$meta$min_precision )episodic_validate_rethreshold( result, min_recall = result$meta$min_recall, min_precision = result$meta$min_precision )
result |
An |
min_recall, min_precision
|
The thresholds to apply instead. |
It re-derives everything the thresholds touch and nothing else: which outbreaks count as detected, when, how badly they fragmented, and which clusters count as true positives. The clusters that were raised, the runs that raised them and the cases they held are what they were - those are measurements, and no threshold changes them.
An episodic_validation object of the same shape, with
meta$min_recall and meta$min_precision updated and
meta$rethresholded_from recording what it was re-matched from.
result <- episodic_validate_detection() strict <- episodic_validate_rethreshold(result, min_recall = 0.8) strict$summary[strict$summary$metric == "sensitivity", ]result <- episodic_validate_detection() strict <- episodic_validate_rethreshold(result, min_recall = 0.8) strict$summary[strict$summary$metric == "sensitivity", ]