88# ' @param redcap_uri REDCap API URI.
99# ' @param token REDCap API token.
1010# ' @param checks Character vector of check groups to run.
11- # ' @param sparse_threshold Missingness threshold used to flag unexpectedly sparse
12- # ' fields.
13- # ' @param outlier_iqr_multiplier Multiplier used for IQR-based numeric outlier
14- # ' detection.
15- # ' @param progress Progress display mode. Use `"auto"` to show progress only in
16- # ' interactive sessions, `"none"` to suppress progress, or `"show"` to force
17- # ' progress output.
11+ # ' @param sparse_threshold Number greater than 0 and less than or equal to 1.
12+ # ' A field is flagged as unexpectedly sparse when its missing rate among
13+ # ' applicable rows is greater than or equal to this value. The default `0.95`
14+ # ' flags fields with at least 95 percent missing values.
15+ # ' @param outlier_iqr_multiplier Positive number used to define IQR outlier
16+ # ' bounds as Q1 minus this value times the IQR and Q3 plus this value times the
17+ # ' IQR. Smaller values produce narrower bounds and more findings. The default
18+ # ' is `3`.
19+ # ' @param progress Progress display mode. `"auto"` enables normally throttled
20+ # ' progress updates only in interactive sessions. `"none"` suppresses
21+ # ' progress. `"show"` enables progress in all sessions and forces every
22+ # ' update to render.
1823# '
1924# ' @details
2025# ' Current `findings$issue` values by `findings$check`:
3540# ' - `operational`
3641# ' - `incomplete_form_status`
3742# ' - `consistency`
43+ # ' - `checkbox_no_values_selected`
3844# ' - `checkbox_none_with_other`
3945# '
4046# ' @returns A list with `findings`, `summaries`, and `metadata` elements.
5258# ' structure, and branching logic. Missingness is only assessed where the raw
5359# ' REDCap form status column `<form_name>_complete` is `1`/Unverified or
5460# ' `2`/Complete. Status `0`/Incomplete is handled by operational checks and
55- # ' does not contribute to missingness, even when REDCap exports checkbox
56- # ' choices as `0`.
61+ # ' does not contribute to missingness. Checkbox parent fields are observed
62+ # ' when their choice exports contain explicit values, including an all-zero
63+ # ' no-selection state; only an all-missing set of choice exports is missing.
5764# ' Branching expressions that use unsupported REDCap functions or smart
5865# ' variables are treated as applicable so required values remain reviewable.
5966# '
@@ -794,14 +801,21 @@ get_field_values <- function(project, field) {
794801 return (rep(NA_character_ , nrow(project $ data )))
795802 }
796803
797- checked <- as.data.frame(map(project $ data [checkbox_columns ], get_is_checked ))
804+ checkbox_data <- project $ data [checkbox_columns ]
805+ checked <- as.data.frame(map(checkbox_data , get_is_checked ))
806+ missing <- as.data.frame(map(checkbox_data , get_is_missing ))
798807 checked_names <- sub(paste0(" ^" , field , " ___" ), " " , checkbox_columns )
799808 map_chr(seq_len(nrow(checked )), \(row ) {
800809 row_checked <- unlist(checked [row , ], use.names = FALSE )
801- if (! any(row_checked , na.rm = TRUE )) {
810+ row_missing <- unlist(missing [row , ], use.names = FALSE )
811+ if (all(row_missing )) {
802812 return (NA_character_ )
803813 }
804814
815+ if (! any(row_checked , na.rm = TRUE )) {
816+ return (" No selections" )
817+ }
818+
805819 paste(checked_names [row_checked ], collapse = " , " )
806820 })
807821}
@@ -1358,11 +1372,16 @@ get_consistency_findings <- function(project) {
13581372
13591373 bind_rows(map(names(checkbox_groups ), \(field ) {
13601374 columns <- checkbox_groups [[field ]]
1375+ no_selection_finding <- get_checkbox_no_selection_finding(
1376+ project ,
1377+ field ,
1378+ columns
1379+ )
13611380 none_columns <- get_none_choice_columns(choices , field , columns )
13621381 other_columns <- setdiff(columns , none_columns )
13631382
13641383 if (length(none_columns ) == 0 || length(other_columns ) == 0 ) {
1365- return (get_empty_findings() )
1384+ return (no_selection_finding )
13661385 }
13671386
13681387 none_checked <- Reduce(
@@ -1373,40 +1392,84 @@ get_consistency_findings <- function(project) {
13731392 `|` ,
13741393 lapply(project $ data [other_columns ], get_is_checked )
13751394 )
1376- contradiction <- none_checked & other_checked
1395+ contradiction <- none_checked &
1396+ other_checked &
1397+ get_field_applicable_rows(project , field )
13771398
13781399 if (! any(contradiction , na.rm = TRUE )) {
1379- return (get_empty_findings() )
1400+ return (no_selection_finding )
13801401 }
13811402
13821403 metadata_row <- get_metadata_row(project , field )
1383- tibble(
1384- check = " consistency" ,
1385- issue = " checkbox_none_with_other" ,
1386- severity = " warning" ,
1387- scope = " record_field" ,
1388- record_id = as.character(project $ data [[project $ record_id_field ]][
1389- contradiction
1390- ]),
1391- form_name = metadata_row $ form_name ,
1392- event_name = get_event_values(project $ data , contradiction ),
1393- repeat_instrument = get_repeat_instrument_values(
1394- project $ data ,
1395- contradiction
1396- ),
1397- repeat_instance = get_repeat_instance_values(project $ data , contradiction ),
1398- field_name = field ,
1399- value = paste(none_columns , collapse = " , " ),
1400- expected = " None option not selected with other options" ,
1401- message = paste(
1402- " Checkbox field" ,
1403- field ,
1404- " has a none option selected with another option."
1404+ bind_rows(
1405+ no_selection_finding ,
1406+ tibble(
1407+ check = " consistency" ,
1408+ issue = " checkbox_none_with_other" ,
1409+ severity = " warning" ,
1410+ scope = " record_field" ,
1411+ record_id = as.character(project $ data [[project $ record_id_field ]][
1412+ contradiction
1413+ ]),
1414+ form_name = metadata_row $ form_name ,
1415+ event_name = get_event_values(project $ data , contradiction ),
1416+ repeat_instrument = get_repeat_instrument_values(
1417+ project $ data ,
1418+ contradiction
1419+ ),
1420+ repeat_instance = get_repeat_instance_values(
1421+ project $ data ,
1422+ contradiction
1423+ ),
1424+ field_name = field ,
1425+ value = paste(none_columns , collapse = " , " ),
1426+ expected = " None option not selected with other options" ,
1427+ message = paste(
1428+ " Checkbox field" ,
1429+ field ,
1430+ " has a none option selected with another option."
1431+ )
14051432 )
14061433 )
14071434 }))
14081435}
14091436
1437+ get_checkbox_no_selection_finding <- function (project , field , columns ) {
1438+ assessed <- get_field_applicable_rows(project , field )
1439+ explicit <- as.data.frame(map(
1440+ project $ data [columns ],
1441+ \(value ) ! get_is_missing(value )
1442+ ))
1443+ checked <- as.data.frame(map(project $ data [columns ], get_is_checked ))
1444+ assessed_explicit <- assessed & rowSums(explicit ) > 0
1445+
1446+ if (
1447+ ! any(assessed_explicit ) ||
1448+ any(rowSums(checked [assessed_explicit , , drop = FALSE ]) > 0 )
1449+ ) {
1450+ return (get_empty_findings())
1451+ }
1452+
1453+ metadata_row <- get_metadata_row(project , field )
1454+ tibble(
1455+ check = " consistency" ,
1456+ issue = " checkbox_no_values_selected" ,
1457+ severity = " info" ,
1458+ scope = " field" ,
1459+ record_id = NA_character_ ,
1460+ form_name = metadata_row $ form_name ,
1461+ event_name = NA_character_ ,
1462+ field_name = field ,
1463+ value = " 0 selected options" ,
1464+ expected = " Confirm that no selections are intentional" ,
1465+ message = paste(
1466+ " Checkbox field" ,
1467+ field ,
1468+ " has no selected options among assessed rows."
1469+ )
1470+ )
1471+ }
1472+
14101473get_none_choice_columns <- function (choices , field , columns ) {
14111474 none_labels <- c(" none" , " no" , " not applicable" , " n/a" , " na" , " none of the above" )
14121475 none_values <- choices | >
0 commit comments