Skip to content

Commit 7b30e4e

Browse files
authored
Merge pull request #79 new condition class
new condition class
2 parents 252026e + 680c16f commit 7b30e4e

19 files changed

Lines changed: 1128 additions & 869 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.2.9000
4+
Version: 0.1.3.9000
55
Authors@R: c(
66
person(
77
"Matthew", "Valko",

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Generated by roxygen2: do not edit by hand
22

3+
export(Condition)
34
export(Population)
45
export(Timer)
56
export(Trial)
@@ -25,4 +26,5 @@ importFrom(dplyr,select)
2526
importFrom(dplyr,ungroup)
2627
importFrom(rlang,":=")
2728
importFrom(rlang,enquos)
29+
importFrom(rlang,quos)
2830
importFrom(utils,tail)

R/Condition.R

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
#' Condition: Stateful trigger and analysis unit
2+
#'
3+
#' @description
4+
#' A `Condition` encapsulates a single trigger rule that is evaluated against
5+
#' a data snapshot at each simulated timepoint. It combines three concerns:
6+
#'
7+
#' \enumerate{
8+
#' \item **Filtering** — a `dplyr::filter()` expression selects the rows
9+
#' relevant to this condition (e.g. "only enrolled subjects in arm A").
10+
#' \item **Analysis** — an optional function transforms the filtered snapshot
11+
#' into a result (e.g. a t-test, a subject count, a Go/No-Go decision).
12+
#' \item **Trigger bookkeeping** — the condition fires only when the
13+
#' filtered data is non-empty, the cooldown period has elapsed since the
14+
#' last trigger, and the maximum trigger count has not been reached.
15+
#' }
16+
#'
17+
#' `Condition` objects are stored in `trial$conditions` and evaluated by
18+
#' [`Trial`]`$run()` at each timepoint.
19+
#'
20+
#' @details
21+
#' **Three-gate logic.** A trigger fires only when all three gates pass:
22+
#' \enumerate{
23+
#' \item The filtered snapshot contains at least one row.
24+
#' \item `current_time - last_trigger_time >= cooldown` (or the condition
25+
#' has never fired before).
26+
#' \item `trigger_count < max_triggers`.
27+
#' }
28+
#' If any gate fails, `check_conditions()` returns an empty list and state
29+
#' is not updated.
30+
#'
31+
#' 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
34+
#' provided, the filtered data frame is returned as-is with a warning.
35+
#'
36+
#' @section Fields:
37+
#' \describe{
38+
#' \item{\code{where}}{`list` of quosures (from `rlang::quos()`) used as
39+
#' `dplyr::filter()` predicates. Pass `NULL` or an empty list to skip
40+
#' filtering and pass the full snapshot to the analysis.}
41+
#' \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.}
45+
#' \item{\code{name}}{`character` or `NULL`. Key used to label the result
46+
#' in the returned list. Falls back to `1L` when `NULL`.}
47+
#' \item{\code{cooldown}}{`numeric`. Minimum time units that must elapse
48+
#' between consecutive triggers. Default `0` (no cooldown).}
49+
#' \item{\code{max_triggers}}{`integer`. Maximum number of times this
50+
#' condition may fire. Use `Inf` for unlimited. Default `1L`.}
51+
#' \item{\code{trigger_count}}{`integer`. Number of successful triggers so
52+
#' far. Initialised to `0L`.}
53+
#' \item{\code{last_trigger_time}}{`numeric`. Calendar time of the most
54+
#' recent successful trigger. Initialised to `NA_real_`.}
55+
#' }
56+
#'
57+
#' @section Methods:
58+
#' \describe{
59+
#' \item{\code{$new(where, analysis, name, cooldown, max_triggers)}}{
60+
#' Construct a new `Condition`. All arguments except `where` are
61+
#' optional. `cooldown` must be a single non-negative number;
62+
#' `max_triggers` must be a single non-negative integer or `Inf`.}
63+
#' \item{\code{$check_conditions(locked_data, current_time)}}{
64+
#' Evaluate the condition against `locked_data` at `current_time`.
65+
#' Returns a named `list` containing the analysis result (or filtered
66+
#' data frame) if the condition fires, or an empty `list` otherwise.
67+
#' On a successful trigger, `trigger_count` is incremented and
68+
#' `last_trigger_time` is updated.}
69+
#' }
70+
#'
71+
#' @seealso
72+
#' \itemize{
73+
#' \item [`Timer`] for managing trial timepoints
74+
#' \item [`Trial`] for running the simulation and iterating over conditions
75+
#' \item [`trigger_by_calendar()`] and [`trigger_by_fraction()`] for
76+
#' convenient `Condition` constructors
77+
#' \item [`dplyr::filter()`] for predicate syntax
78+
#' }
79+
#'
80+
#' @examples
81+
#' # Build a snapshot data frame
82+
#' snapshot <- data.frame(
83+
#' arm = c("A", "A", "A", "B"),
84+
#' status = c("active", "active", "active", "active"),
85+
#' stringsAsFactors = FALSE
86+
#' )
87+
#'
88+
#' # 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)
91+
#' }
92+
#'
93+
#' # Condition fires once when arm A has active subjects (max_triggers = 1)
94+
#' cond <- Condition$new(
95+
#' where = rlang::quos(arm == "A", status == "active"),
96+
#' analysis = count_fn,
97+
#' name = "interim_A",
98+
#' cooldown = 0,
99+
#' max_triggers = 1L
100+
#' )
101+
#'
102+
#' # First call: fires and returns analysis result
103+
#' res <- cond$check_conditions(snapshot, current_time = 5)
104+
#' res[["interim_A"]] # data.frame(n_active = 3, fired_at = 5)
105+
#'
106+
#' # Second call: does not fire (max_triggers already reached)
107+
#' res2 <- cond$check_conditions(snapshot, current_time = 6)
108+
#' length(res2) # 0
109+
#'
110+
#' @importFrom dplyr filter
111+
#' @export
112+
Condition <- R6::R6Class(
113+
classname = "Condition",
114+
115+
public = list(
116+
# --- fields ---
117+
#' @field where `list` of quosures (`rlang::quos()`) used as `dplyr::filter()`
118+
#' predicates. `NULL` or empty list passes the full snapshot.
119+
where = NULL,
120+
121+
#' @field analysis `function` or `NULL`. Called as
122+
#' `analysis(filtered_data, current_time)` on a successful trigger.
123+
analysis = NULL,
124+
125+
#' @field name `character` or `NULL`. Key labelling the result in the output
126+
#' list. Falls back to `1L` when `NULL`.
127+
name = NULL,
128+
129+
#' @field cooldown `numeric`. Minimum time units between consecutive
130+
#' triggers. Default `0`.
131+
cooldown = 0,
132+
133+
#' @field max_triggers `integer` or `Inf`. Maximum number of times this
134+
#' condition may fire. Default `1L`.
135+
max_triggers = 1L,
136+
137+
#' @field trigger_count `integer`. Number of successful triggers so far.
138+
#' Initialised to `0L`.
139+
trigger_count = 0L,
140+
141+
#' @field last_trigger_time `numeric`. Calendar time of the most recent
142+
#' successful trigger. `NA_real_` until first trigger.
143+
last_trigger_time = NA_real_,
144+
145+
# --- constructor ---
146+
#' @description
147+
#' Create a new `Condition` instance.
148+
#'
149+
#' @param where `list` of quosures (from `rlang::quos()`) used as filter
150+
#' predicates. Pass `NULL` or omit to use the full snapshot.
151+
#' @param analysis `function` or `NULL`. Called as
152+
#' `analysis(filtered_data, current_time)` on a successful trigger.
153+
#' @param name `character` or `NULL`. Result key. Defaults to `1L`.
154+
#' @param cooldown `numeric`. Minimum time between triggers. Default `0`.
155+
#' @param max_triggers `integer`. Maximum trigger count. Default `1L`.
156+
#' Use `Inf` for unlimited.
157+
#'
158+
#' @return A new `Condition` instance.
159+
initialize = function(
160+
where = NULL,
161+
analysis = NULL,
162+
name = NULL,
163+
cooldown = 0,
164+
max_triggers = 1L
165+
) {
166+
self$where <- where
167+
self$analysis <- analysis
168+
self$name <- name
169+
170+
cooldown <- as.numeric(cooldown)
171+
if (length(cooldown) != 1L || cooldown < 0 || is.na(cooldown)) {
172+
stop("`cooldown` must be a single non-negative number.")
173+
}
174+
175+
if (length(max_triggers) == 1L && is.infinite(max_triggers) && max_triggers > 0) {
176+
# Inf means unlimited — keep as-is
177+
} else {
178+
max_triggers <- as.integer(max_triggers)
179+
if (length(max_triggers) != 1L || is.na(max_triggers) || max_triggers < 0L) {
180+
stop("`max_triggers` must be a non-negative integer (use Inf for unlimited).")
181+
}
182+
}
183+
184+
self$cooldown <- cooldown
185+
self$max_triggers <- max_triggers
186+
},
187+
188+
# --- methods ---
189+
190+
#' @description
191+
#' Evaluate this condition against a data snapshot.
192+
#'
193+
#' Applies the three-gate logic: non-empty filter result, cooldown
194+
#' elapsed, and trigger count below `max_triggers`. Returns the analysis
195+
#' result (or filtered data) on a successful trigger, or an empty list
196+
#' otherwise.
197+
#'
198+
#' @param locked_data `data.frame` The trial snapshot at the current time.
199+
#' @param current_time `numeric` Calendar time of the current timepoint.
200+
#'
201+
#' @return Named `list` with one entry (the analysis result) on success,
202+
#' or an empty `list` if the condition did not fire.
203+
check_conditions = function(locked_data, current_time) {
204+
stopifnot(is.data.frame(locked_data))
205+
206+
results <- list()
207+
208+
key <- if (!is.null(self$name) && nzchar(self$name)) self$name else 1L
209+
210+
# Filter snapshot (dplyr semantics: NA in predicates drops rows)
211+
df_i <- if (!is.null(self$where) && length(self$where) > 0) {
212+
dplyr::filter(locked_data, !!!self$where)
213+
} else {
214+
locked_data
215+
}
216+
217+
# Gate 1: non-empty match
218+
if (nrow(df_i) == 0L) return(results)
219+
220+
# Gate 2: hard cap on number of triggers
221+
if (is.finite(self$max_triggers) && self$trigger_count >= self$max_triggers) {
222+
return(results)
223+
}
224+
225+
# Gate 3: cooldown
226+
if (is.finite(self$last_trigger_time)) {
227+
if ((current_time - self$last_trigger_time) < self$cooldown) {
228+
return(results)
229+
}
230+
}
231+
232+
if (is.function(self$analysis)) {
233+
results[[key]] <- self$analysis(df_i, current_time)
234+
} else {
235+
results[[key]] <- df_i
236+
warning(
237+
sprintf(
238+
" returning filtered data as is because condition '%s' has no applicable analysis \n",
239+
key
240+
),
241+
call. = FALSE
242+
)
243+
}
244+
245+
# Update trigger state after a successful trigger
246+
self$trigger_count <- self$trigger_count + 1L
247+
self$last_trigger_time <- current_time
248+
249+
results
250+
}
251+
252+
) # end public
253+
) # end class

0 commit comments

Comments
 (0)