Skip to content

Commit 67222cb

Browse files
authored
Merge pull request #91 from Remove rlang::expr, add custom trigger arithmetic
Remove rlang::expr, add custom trigger arithmetic
2 parents 7b30e4e + 1b2328d commit 67222cb

26 files changed

Lines changed: 875 additions & 276 deletions

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: rxsim
22
Type: Package
33
Title: Reducing friction for Randomized Clinical Trial simulations
4-
Version: 0.1.3.9000
4+
Version: 0.1.4.9000
55
Authors@R: c(
66
person(
77
"Matthew", "Valko",

NAMESPACE

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
# Generated by roxygen2: do not edit by hand
22

3+
S3method("&",rxsim_trigger)
4+
S3method("|",rxsim_trigger)
35
export(Condition)
46
export(Population)
57
export(Timer)
68
export(Trial)
79
export(add_timepoints)
10+
export(calendar_trigger)
811
export(clone_trial)
912
export(collect_results)
13+
export(count_trigger)
14+
export(enroll_trigger)
1015
export(gen_plan)
1116
export(gen_population)
1217
export(gen_timepoints)
@@ -16,6 +21,7 @@ export(replicate_trial)
1621
export(run_trials)
1722
export(trigger_by_calendar)
1823
export(trigger_by_fraction)
24+
export(value_trigger)
1925
export(vector_to_dataframe)
2026
importFrom(dplyr,.data)
2127
importFrom(dplyr,arrange)
@@ -26,5 +32,4 @@ importFrom(dplyr,select)
2632
importFrom(dplyr,ungroup)
2733
importFrom(rlang,":=")
2834
importFrom(rlang,enquos)
29-
importFrom(rlang,quos)
3035
importFrom(utils,tail)

R/Condition.R

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
.trigger_to_quosures <- function(trigger) {
2+
if (!is.null(trigger$type)) {
3+
expr <- if (identical(trigger$type, "value")) {
4+
call(trigger$op, call("[[", quote(.data), trigger$col), trigger$rhs)
5+
} else {
6+
call(trigger$op,
7+
call("sum", call("!", call("is.na", call("[[", quote(.data), trigger$col)))),
8+
trigger$rhs)
9+
}
10+
return(list(rlang::new_quosure(expr, env = rlang::current_env())))
11+
}
12+
13+
if (identical(trigger$combinator, "&")) {
14+
return(unlist(lapply(trigger$predicates, .trigger_to_quosures), recursive = FALSE))
15+
}
16+
17+
left_quos <- .trigger_to_quosures(trigger$left)
18+
right_quos <- .trigger_to_quosures(trigger$right)
19+
expr <- call("|", rlang::get_expr(left_quos[[1L]]), rlang::get_expr(right_quos[[1L]]))
20+
list(rlang::new_quosure(expr, env = rlang::current_env()))
21+
}
22+
123
#' Condition: Stateful trigger and analysis unit
224
#'
325
#' @description
@@ -29,8 +51,9 @@
2951
#' is not updated.
3052
#'
3153
#' On a successful trigger, the condition calls
32-
#' `analysis(filtered_data, current_time)` and stores the result under
33-
#' `name` (or `1L` when no name is set). If no analysis function is
54+
#' `analysis(df, current_time, ...)` and stores the result under
55+
#' `name` (or `1L` when no name is set). Any values in `analysis_args` are
56+
#' appended as additional named arguments. If no analysis function is
3457
#' provided, the filtered data frame is returned as-is with a warning.
3558
#'
3659
#' @section Fields:
@@ -39,9 +62,11 @@
3962
#' `dplyr::filter()` predicates. Pass `NULL` or an empty list to skip
4063
#' filtering and pass the full snapshot to the analysis.}
4164
#' \item{\code{analysis}}{`function` or `NULL`. Called as
42-
#' `analysis(filtered_data, current_time)` on a successful trigger.
43-
#' Should return a `data.frame` or named list. If `NULL`, the filtered
44-
#' data frame is returned with a warning.}
65+
#' `analysis(df, current_time, ...)` on a successful trigger, where `...`
66+
#' are any values from `analysis_args`. Should return a `data.frame` or
67+
#' named list. If `NULL`, the filtered data frame is returned with a warning.}
68+
#' \item{\code{analysis_args}}{`list` or `NULL`. Named list of extra arguments
69+
#' injected into every call to `analysis`.}
4570
#' \item{\code{name}}{`character` or `NULL`. Key used to label the result
4671
#' in the returned list. Falls back to `1L` when `NULL`.}
4772
#' \item{\code{cooldown}}{`numeric`. Minimum time units that must elapse
@@ -74,6 +99,8 @@
7499
#' \item [`Trial`] for running the simulation and iterating over conditions
75100
#' \item [`trigger_by_calendar()`] and [`trigger_by_fraction()`] for
76101
#' convenient `Condition` constructors
102+
#' \item [value_trigger()], [count_trigger()], [enroll_trigger()],
103+
#' [calendar_trigger()] for building safe trigger specifications
77104
#' \item [`dplyr::filter()`] for predicate syntax
78105
#' }
79106
#'
@@ -86,8 +113,8 @@
86113
#' )
87114
#'
88115
#' # Analysis function: count active subjects per arm
89-
#' count_fn <- function(dat, current_time) {
90-
#' data.frame(n_active = nrow(dat), fired_at = current_time)
116+
#' count_fn <- function(df, current_time) {
117+
#' data.frame(n_active = nrow(df), fired_at = current_time)
91118
#' }
92119
#'
93120
#' # Condition fires once when arm A has active subjects (max_triggers = 1)
@@ -115,13 +142,19 @@ Condition <- R6::R6Class(
115142
public = list(
116143
# --- fields ---
117144
#' @field where `list` of quosures (`rlang::quos()`) used as `dplyr::filter()`
118-
#' predicates. `NULL` or empty list passes the full snapshot.
145+
#' predicates, or an `rxsim_trigger` (converted automatically). `NULL` or
146+
#' empty list passes the full snapshot.
119147
where = NULL,
120148

121149
#' @field analysis `function` or `NULL`. Called as
122-
#' `analysis(filtered_data, current_time)` on a successful trigger.
150+
#' `analysis(df, current_time, ...)` on a successful trigger, where `...`
151+
#' are any values from `analysis_args`.
123152
analysis = NULL,
124153

154+
#' @field analysis_args `list` or `NULL`. Named list of extra values injected
155+
#' into the analysis function call as additional named arguments.
156+
analysis_args = NULL,
157+
125158
#' @field name `character` or `NULL`. Key labelling the result in the output
126159
#' list. Falls back to `1L` when `NULL`.
127160
name = NULL,
@@ -146,26 +179,33 @@ Condition <- R6::R6Class(
146179
#' @description
147180
#' Create a new `Condition` instance.
148181
#'
149-
#' @param where `list` of quosures (from `rlang::quos()`) used as filter
150-
#' predicates. Pass `NULL` or omit to use the full snapshot.
182+
#' @param where `rxsim_trigger` (converted automatically to quosures), a
183+
#' `list` of quosures from `rlang::quos()`, or `NULL` to use the full
184+
#' snapshot.
151185
#' @param analysis `function` or `NULL`. Called as
152-
#' `analysis(filtered_data, current_time)` on a successful trigger.
186+
#' `analysis(df, current_time, ...)` on a successful trigger, where `...`
187+
#' are the values from `analysis_args`.
188+
#' @param analysis_args `list` or `NULL`. Named list of extra arguments
189+
#' passed to the analysis function after `df` and `current_time`.
153190
#' @param name `character` or `NULL`. Result key. Defaults to `1L`.
154191
#' @param cooldown `numeric`. Minimum time between triggers. Default `0`.
155192
#' @param max_triggers `integer`. Maximum trigger count. Default `1L`.
156193
#' Use `Inf` for unlimited.
157194
#'
158195
#' @return A new `Condition` instance.
159196
initialize = function(
160-
where = NULL,
161-
analysis = NULL,
162-
name = NULL,
163-
cooldown = 0,
164-
max_triggers = 1L
197+
where = NULL,
198+
analysis = NULL,
199+
analysis_args = NULL,
200+
name = NULL,
201+
cooldown = 0,
202+
max_triggers = 1L
165203
) {
166-
self$where <- where
167-
self$analysis <- analysis
168-
self$name <- name
204+
if (inherits(where, "rxsim_trigger")) where <- .trigger_to_quosures(where)
205+
self$where <- where
206+
self$analysis <- analysis
207+
self$analysis_args <- analysis_args
208+
self$name <- name
169209

170210
cooldown <- as.numeric(cooldown)
171211
if (length(cooldown) != 1L || cooldown < 0 || is.na(cooldown)) {
@@ -230,7 +270,7 @@ Condition <- R6::R6Class(
230270
}
231271

232272
if (is.function(self$analysis)) {
233-
results[[key]] <- self$analysis(df_i, current_time)
273+
results[[key]] <- do.call(self$analysis, c(list(df_i, current_time), self$analysis_args))
234274
} else {
235275
results[[key]] <- df_i
236276
warning(

R/helpers.R

Lines changed: 2 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,3 @@
1-
#' Trigger Analysis at a Calendar Time
2-
#'
3-
#' Builds a [`Condition`] that fires when the trial clock reaches a specified
4-
#' calendar time. The returned `Condition` should be passed to
5-
#' `Trial$new(conditions = list(...))`.
6-
#'
7-
#' @param cal_time `numeric` Calendar time(s) at which to trigger.
8-
#' @param analysis `function` or `NULL` Optional analysis function called as
9-
#' `analysis(filtered_data, current_time)`. If `NULL`, the filtered snapshot
10-
#' is returned as-is with a warning.
11-
#' @param name `character` or `NULL` Result key. Defaults to
12-
#' `"cal_time_<cal_time>"`.
13-
#'
14-
#' @return A [`Condition`] object.
15-
#'
16-
#' @seealso [Condition], [trigger_by_fraction()], [Trial].
17-
#'
18-
#' @export
19-
#'
20-
#' @examples
21-
#' cond <- trigger_by_calendar(
22-
#' cal_time = 12,
23-
#' analysis = function(df, current_time) {
24-
#' data.frame(n_enrolled = sum(!is.na(df$enroll_time)))
25-
#' }
26-
#' )
27-
#'
28-
#' @importFrom rlang quos
29-
#' @importFrom dplyr .data
30-
trigger_by_calendar <- function(cal_time, analysis = NULL, name = NULL) {
31-
if (missing(cal_time)) stop("`cal_time` is required.")
32-
stopifnot(is.numeric(cal_time))
33-
if (is.null(name)) name <- paste0("cal_time_", paste(cal_time, collapse = "_"))
34-
35-
Condition$new(
36-
where = rlang::quos(.data$time %in% !!cal_time),
37-
analysis = analysis,
38-
name = name
39-
)
40-
}
41-
42-
#' Trigger Analysis at a Sample Fraction
43-
#'
44-
#' Builds a [`Condition`] that fires when a given fraction of the target sample
45-
#' has been enrolled. The returned `Condition` should be passed to
46-
#' `Trial$new(conditions = list(...))`.
47-
#'
48-
#' @param fraction `numeric` Sample fraction (0 < fraction <= 1).
49-
#' @param sample_size `integer` Target sample size.
50-
#' @param analysis `function` or `NULL` Optional analysis function called as
51-
#' `analysis(filtered_data, current_time)`. If `NULL`, the filtered snapshot
52-
#' is returned as-is with a warning.
53-
#' @param name `character` or `NULL` Result key. Defaults to
54-
#' `"frac_<fraction>"`.
55-
#'
56-
#' @return A [`Condition`] object.
57-
#'
58-
#' @seealso [Condition], [trigger_by_calendar()], [Trial].
59-
#'
60-
#' @export
61-
#'
62-
#' @examples
63-
#' cond <- trigger_by_fraction(
64-
#' fraction = 0.5,
65-
#' sample_size = 100,
66-
#' analysis = function(df, current_time) {
67-
#' data.frame(n_enrolled = sum(!is.na(df$enroll_time)))
68-
#' }
69-
#' )
70-
#'
71-
#' @importFrom rlang quos
72-
#' @importFrom dplyr .data
73-
trigger_by_fraction <- function(fraction, sample_size, analysis = NULL, name = NULL) {
74-
if (missing(fraction) || missing(sample_size)) stop("`fraction` and `sample_size` are required.")
75-
stopifnot(is.numeric(sample_size) && length(sample_size) == 1L)
76-
stopifnot(fraction > 0 && fraction <= 1)
77-
if (is.null(name)) name <- paste0("frac_", fraction)
78-
target_n <- fraction * sample_size
79-
80-
Condition$new(
81-
where = rlang::quos(sum(!is.na(.data$enroll_time)) >= !!target_n),
82-
analysis = analysis,
83-
name = name
84-
)
85-
}
861

872
#' Add Timepoints to a Timer
883
#'
@@ -159,8 +74,8 @@ add_timepoints <- function(timer, df) {
15974
#' )
16075
#' an_gens <- list(
16176
#' final = list(
162-
#' trigger = rlang::exprs(sum(!is.na(enroll_time)) >= 20L),
163-
#' analysis = function(df, timer) {
77+
#' trigger = count_trigger("enroll_time", ">=", 20L),
78+
#' analysis = function(df, current_time) {
16479
#' data.frame(mean_ctrl = mean(df$data[df$arm == "control"]))
16580
#' }
16681
#' )

R/trials.R

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,10 @@ gen_population <- function(name, generator, sample_size = 1) {
7171
#' @param allocation `numeric` vector of arm allocation ratios.
7272
#' @param enrollment `function` that generates inter-enrollment times.
7373
#' @param dropout `function` that generates inter-dropout times.
74-
#' @param analysis_generators `list` (named) of analysis trigger specifications.
74+
#' @param analysis_generators `list` (named) of analysis specifications. Each
75+
#' `$trigger` must be an `rxsim_trigger` object created by
76+
#' `value_trigger()`, `count_trigger()`, `enroll_trigger()`, or
77+
#' `calendar_trigger()`.
7578
#' @param population_generators `list` (named) of population generator functions.
7679
#' @param n `integer` Number of trials to create.
7780
#'
@@ -140,10 +143,18 @@ replicate_trial <- function(
140143

141144
trials <- lapply(seq_len(n), function(i) {
142145
conditions <- lapply(names(analysis_generators), function(aname) {
146+
trigger <- analysis_generators[[aname]]$trigger
147+
if (!inherits(trigger, "rxsim_trigger")) {
148+
stop(
149+
"Trigger for '", aname, "' must be an `rxsim_trigger` object. ",
150+
"Use `value_trigger()`, `count_trigger()`, `enroll_trigger()`, or `calendar_trigger()`."
151+
)
152+
}
143153
Condition$new(
144-
where = analysis_generators[[aname]]$trigger,
145-
analysis = analysis_generators[[aname]]$analysis,
146-
name = aname
154+
where = trigger,
155+
analysis = analysis_generators[[aname]]$analysis,
156+
analysis_args = analysis_generators[[aname]]$analysis_args,
157+
name = aname
147158
)
148159
})
149160
Trial$new(

0 commit comments

Comments
 (0)