# Reproducible R Analysis With renv
```elixir
Mix.install([
{:rx, "~> 0.1"}
])
```
## Shared Analysis Scenario
This notebook models a common workflow: an Elixir Livebook opens an R analysis
project with a pinned `renv.lock`, starts Rx inside that project, reads project
configuration from R, and runs a small analysis that depends on the packages in
the project library.
The demo uses `datasets::airquality` and `jsonlite` so it can run without
downloading a large package set. On a real team project, replace the temporary
demo path with the project directory that contains your committed `renv.lock`.
For an already-restored project, call
`:ok = Rx.renv_init("/path/to/your/project", restore: false)`.
To populate the project library from the lockfile first, call
`:ok = Rx.renv_init("/path/to/your/project", restore: true)`.
## Prepare A Demo Project
The helper below builds a disposable project that behaves like a checked-out R
project: it has a project file, an `renv.lock`, a project-local library, and a
small JSON configuration file. It reports `%{status: "skipped"}` when local R
prerequisites are missing instead of writing to a user library.
```elixir
defmodule RenvAnalysisExample do
@moduledoc false
def prepare do
with {:ok, rscript} <- find_rscript(),
:ok <- require_r_package(rscript, "renv"),
:ok <- require_r_package(rscript, "jsonlite") do
prepare_project(rscript)
end
end
def run_analysis(%{status: "ready"} = context) do
{result, _globals} =
Rx.eval(
~S"""
project <- Sys.getenv("RX_RENV_PROJECT")
config <- jsonlite::fromJSON(file.path(project, "analysis_config.json"))
observations <- stats::na.omit(
datasets::airquality[, c("Ozone", "Solar.R", "Wind", "Temp")]
)
fit <- lm(Ozone ~ Solar.R + Wind + Temp, data = observations)
prediction_days <- as.data.frame(config$prediction_days)
prediction_days$predicted_ozone <- as.numeric(predict(fit, newdata = prediction_days))
coefficient_matrix <- coef(summary(fit))
coefficients <- data.frame(
term = rownames(coefficient_matrix),
estimate = unname(coefficient_matrix[, "Estimate"]),
std_error = unname(coefficient_matrix[, "Std. Error"]),
p_value = unname(coefficient_matrix[, "Pr(>|t|)"]),
row.names = NULL
)
report <- list(
status = "completed",
project = config$project,
rx_project = Sys.getenv("RX_RENV_PROJECT"),
lockfile = Sys.getenv("RX_RENV_LOCKFILE"),
lib_paths = .libPaths(),
jsonlite_version = as.character(utils::packageVersion("jsonlite")),
r_version = as.character(getRversion()),
rows_used = nrow(observations),
model_formula = deparse(formula(fit)),
coefficients = coefficients,
predictions = prediction_days,
model_summary = paste(capture.output(summary(fit)), collapse = "\n"),
session = paste(capture.output(sessionInfo()), collapse = "\n")
)
as.character(
jsonlite::toJSON(report, dataframe = "rows", auto_unbox = TRUE, digits = 5, na = "null")
)
""",
%{}
)
result
|> Rx.decode()
|> Jason.decode!()
|> Map.put("project_directory", context.project)
end
def run_analysis(skipped), do: skipped
defp find_rscript do
case System.find_executable("Rscript") do
nil -> {:skip, "Rscript is not on PATH"}
path -> {:ok, path}
end
end
defp require_r_package(rscript, package) do
source = ~s|cat(requireNamespace("#{package}", quietly = TRUE))|
case System.cmd(rscript, ["--vanilla", "-e", source], stderr_to_stdout: true) do
{"TRUE", 0} -> :ok
{output, _status} -> {:skip, "R package #{package} is unavailable: #{String.trim(output)}"}
end
end
defp prepare_project(rscript) do
root = Path.join(System.tmp_dir!(), "rx-renv-analysis-#{System.unique_integer([:positive])}")
project = Path.join(root, "airquality-project")
renv_root = Path.join(root, "renv-root")
renv_library = Path.join(project, "renv-library")
config_path = Path.join(project, "analysis_config.json")
File.mkdir_p!(project)
config = %{
"project" => "airquality-ozone-forecast",
"prediction_days" => [
%{"Solar.R" => 190, "Wind" => 7.4, "Temp" => 87},
%{"Solar.R" => 250, "Wind" => 9.2, "Temp" => 92}
]
}
File.write!(config_path, Jason.encode!(config))
env = [
{"RENV_PATHS_ROOT", renv_root},
{"RENV_PATHS_LIBRARY", renv_library},
{"RENV_CONFIG_SANDBOX_ENABLED", "FALSE"}
]
source = ~S"""
args <- commandArgs(trailingOnly = TRUE)
if (length(args) > 0L && identical(args[[1]], "--args")) args <- args[-1L]
project <- normalizePath(args[[1]], mustWork = TRUE)
options(renv.consent = TRUE)
if (!requireNamespace("renv", quietly = TRUE)) {
cat("renv unavailable\n", file = stderr())
quit(save = "no", status = 10)
}
if (!requireNamespace("jsonlite", quietly = TRUE)) {
cat("jsonlite unavailable\n", file = stderr())
quit(save = "no", status = 11)
}
lockfile <- file.path(project, "renv.lock")
writeLines(c(
"{",
sprintf(' "R": { "Version": "%s" },', as.character(getRversion())),
' "Packages": {}',
"}"
), lockfile)
renv::init(project = project, bare = TRUE, restart = FALSE, load = FALSE)
library_path <- renv::paths$library(project = project)
dir.create(library_path, recursive = TRUE, showWarnings = FALSE)
source <- find.package("jsonlite")
target <- file.path(library_path, "jsonlite")
if (dir.exists(target)) unlink(target, recursive = TRUE)
if (!file.copy(source, library_path, recursive = TRUE)) {
cat("failed to copy jsonlite into isolated renv library\n", file = stderr())
quit(save = "no", status = 12)
}
lock <- renv::lockfile_read(lockfile, project = project)
lock$Packages$jsonlite <- list(
Package = "jsonlite",
Version = as.character(utils::packageVersion("jsonlite")),
Source = "Repository",
Repository = "CRAN"
)
renv::lockfile_write(lock, lockfile, project = project)
"""
case System.cmd(rscript, ["--vanilla", "-e", source, "--args", project],
env: env,
stderr_to_stdout: true
) do
{_output, 0} ->
{:ok,
%{
status: "prepared",
project: Path.expand(project),
lockfile: Path.expand(Path.join(project, "renv.lock")),
config_path: Path.expand(config_path),
renv_root: Path.expand(renv_root),
renv_library: Path.expand(renv_library),
renv_env: env
}}
{output, status} ->
{:skip, "could not prepare isolated renv project, status #{status}: #{String.trim(output)}"}
end
end
end
```
## Start Rx In That Project
`Rx.renv_init/2` validates the lockfile, checks that `renv` and `jsonlite` are
available to the selected project, and starts the process backend with
`RX_RENV_PROJECT` and `RX_RENV_LOCKFILE` set for the R process.
```elixir
example =
case RenvAnalysisExample.prepare() do
{:ok, context} ->
project = context.project
:ok =
Rx.renv_init(project,
restore: false,
renv_env: context.renv_env
)
context
|> Map.put(:status, "ready")
|> Map.put(:backend, Rx.backend())
{:skip, reason} ->
%{status: "skipped", reason: reason}
end
Map.drop(example, [:renv_env])
```
## Inspect The R Environment
This is the quick sanity check you would usually run before a notebook analysis:
confirm the project path, lockfile path, project library, and package version R
actually sees.
```elixir
renv_environment =
case example do
%{status: "ready"} ->
{result, _globals} =
Rx.eval(
~S"""
list(
project = Sys.getenv("RX_RENV_PROJECT"),
lockfile = Sys.getenv("RX_RENV_LOCKFILE"),
lib_paths = .libPaths(),
jsonlite_version = as.character(utils::packageVersion("jsonlite"))
)
""",
%{}
)
Rx.decode(result)
skipped ->
skipped
end
renv_environment
```
## Run The Analysis
The R code reads the project configuration with `jsonlite::fromJSON()`, fits a
small ozone model against `datasets::airquality`, predicts for the configured
days, and returns a JSON report to Elixir.
```elixir
analysis_report = RenvAnalysisExample.run_analysis(example)
case analysis_report do
%{"status" => "completed"} = report ->
%{
project: report["project"],
rows_used: report["rows_used"],
formula: report["model_formula"],
jsonlite_version: report["jsonlite_version"],
predictions: report["predictions"],
coefficient_terms: Enum.map(report["coefficients"], & &1["term"])
}
skipped ->
skipped
end
```
## Keep The Full Report
The previous cell keeps the display compact. The full report still includes the
project paths, `.libPaths()`, model summary, and `sessionInfo()` output so the
notebook result can be audited later.
```elixir
analysis_report
```
## What Changes Between Projects
Rx treats the selected project, lockfile path, lockfile content, resolved
library paths, and selected `renv` environment as part of the backend identity.
If any of those change, call `Rx.renv_init/2` again and recreate R object
handles in the new session before passing them back to R.
For example, repoint the process backend at another checked-out project with
`:ok = Rx.renv_init("/path/to/another/project", restore: false)`.