Document 04

Coding Standards

Practical guidance for writing R code that is readable, reproducible, and transferable — to collaborators, to reviewers, and to yourself six months from now.

§1 Philosophy

The practices described here represent what works when someone other than the original author needs to understand and build on your code. None of them should feel like extra overhead once they become habit. Most are simply decisions made in advance so you do not have to remake them under pressure.

Exploration is a normal part of computational science. You will write messy, non-linear scripts during the early stages of a project, and that is expected. The standards here describe what to aim for by the time work is shared with others or committed as a permanent record. The gap between a working draft and that standard is not a sign of failure. It is the polishing step.

The core question to hold in mind when writing code is: could someone who was not present when I wrote this reproduce my results, understand my reasoning, and build on my work? If yes, you are on the right track.

§2 Three categories of code

Three categories of code appear throughout a research computing workflow, each with its own purpose, standards, and home in the repository. Knowing which category applies shapes every decision that follows.

Scripts
.R

The primary medium for doing scientific work. Scripts are where analysis happens: data is loaded, transformed, visualized, and saved. Most day-to-day computational work takes the form of scripting.

Rmarkdown
.Rmd → .html

The primary medium for teaching and communicating work. Rmarkdown documents narrate analysis that has already been scripted, producing HTML vignettes that others can read and learn from.

R Packages
CRAN standard

Formal, versioned, tested tools intended for broad or long-term reuse. Packages are shared infrastructure. They are built to higher standards and released publicly on GitHub.

A useful way to think about the relationship: you write a script to run an analysis, then write an Rmarkdown document to explain and share what you found, and eventually extract the most reusable pieces of logic into package functions. Each category builds on the others.

§3 R scripts

The header block

Every .R script begins with a header block that identifies the script, states its purpose, and declares where data comes from and goes. This block should be readable in seconds and answer every practical question someone might have before running the file.

Standard script header
# ============================================================
# Title:   01_20250315_AB_preprocessing-qc.R
# Author:  Author Name (AB)
# Date:    2025-03-15
# Purpose: Quality control filtering and doublet removal for
#          rat lung scRNAseq cohort 1 (n=6 samples)
# ------------------------------------------------------------
# Inputs:
#   /hpc/path/to/data/cohort1_raw_counts.rds
# Outputs:
#   /hpc/path/to/output/01_20250315_AB_filtered.rds
#   figures/01_20250315_AB_qc-metrics.pdf
# ============================================================

# ── Libraries ────────────────────────────────────────────────
library(PackageA)
library(PackageB)

# ── Source helper functions ──────────────────────────────────
source("functions/helpers_qc.R")
source("functions/helpers_viz.R")

# ── File paths ───────────────────────────────────────────────
path_input  <- "/hpc/path/to/data/cohort1_raw_counts.rds"
path_output <- "/hpc/path/to/output/01_20250315_AB_filtered.rds"
path_figs   <- "figures/"

The header block has four parts, always in this order:

PartContentsRequired
Title blockFilename, author and initials, date, plain-language purposeAlways
Inputs / OutputsFull absolute paths to every file read and written by the scriptAlways
Library callsAll library() calls, grouped togetherAlways
File path variablesNamed variables for every input and output pathAlways
Declare paths at the top

File paths should never be embedded inline deep in a script. A reader should be able to open your script, read the header, and immediately know what data it operates on and where results will be saved — before reading a single line of analysis code. Declare every input and output path as a named variable in the header and reference that variable throughout the script. This also makes the script trivial to adapt for a new dataset or a new machine.

Script scope and length

A single script should represent one coherent operation: one stage of an analysis, one logical unit of work. A useful heuristic is that a script should represent roughly one day of computational work to reproduce and understand. When a script starts combining multiple distinct goals, it is time to split it.

Length is not itself the problem; sprawl is. A 500-line script that moves cleanly through a single preprocessing pipeline is well-scoped. A 500-line script that preprocesses data, runs clustering, builds a figure panel, and exports a supplementary table is four scripts that have been merged into one.

On exploratory scripts

Exploratory, stream-of-consciousness scripting is a normal part of doing science computationally — the equivalent of rough notes in a lab notebook. Work this way freely during early-stage analysis. Before sharing that work with others or committing it as the primary record, clean it up: break it into staged scripts, separate reusable logic into helper functions, and make sure inputs and outputs are clearly declared. The cleanup step is expected; it is not evidence that the exploration was wrong.

Section dividers

Within a script, use R's standard section divider syntax to organize code into named sections. RStudio and Positron both recognize this format and render a navigable outline, which makes longer scripts much easier to work with.

Section dividers
# ── Load and inspect data ────────────────────────────────────

obj <- readRDS(path_input)
dim(obj)

# ── QC filtering ─────────────────────────────────────────────

obj <- filter_by_qc(obj, min_features = 200, max_mt_pct = 20)

# ── Save output ──────────────────────────────────────────────

saveRDS(obj, path_output)

Inline comments

For primary working scripts, comment enough to retrace your thinking: note what a step is doing, why a parameter was chosen, where a decision was made. You do not need a comment on every line. For polished scripts intended to be transferred to others or used as teaching material, err toward more — every meaningful block should explain its purpose.

When in doubt, write the comment. Future readers cannot ask you what you were thinking.

§4 Rmarkdown

When to use Rmarkdown vs. a script

Scripts are for doing primary analysis work. Rmarkdown is for teaching, communicating, or creating a permanent readable record of work that has already been done. The typical flow is script first, Rmarkdown second.

A good Rmarkdown document is not a replacement for a script. It is a narrated tour of one. It loads the same data, runs the same or similar code, and produces the same outputs, but does so in a format where prose, code, and figures live together on the page. The intended reader is someone who wants to understand what was done and why, not just run a pipeline.

The YAML header

Every Rmarkdown document begins with a YAML header that identifies the document and controls its output format.

Standard YAML header
---
title: "01 — EDA Overview: Rat Lung scRNAseq Cohort 1"
author: "Author Name"
date: "2025-03-15"
output:
  html_document:
    toc: true
    toc_float: true
    code_folding: hide
    theme: flatly
---

Global chunk options and output cleanliness

The knit HTML output should be clean and readable. Long console outputs, warning messages, and package loading text add noise without informational value. Set global chunk options in the first code chunk of every Rmarkdown file.

Setup chunk — place immediately after YAML
```{r setup, include=FALSE}
knitr::opts_chunk$set(
  echo    = TRUE,   # show code; readers can hide via code_folding
  warning = FALSE,  # suppress warnings from output
  message = FALSE,  # suppress package loading messages
  fig.width  = 8,
  fig.height = 6
)
```

Suppress output that a reader does not need to see. The goal is not to hide information — it is to keep the document focused. If a chunk produces output that is meaningful to the reader, such as a printed summary table, keep it visible.

Chunk naming

Name your chunks. Named chunks produce more useful error messages when knitting fails, make the document outline navigable, and allow selective caching. Use lowercase, hyphen-separated names that describe what the chunk does.

Chunk naming examples
```{r load-data}
```{r qc-filtering}
```{r umap-plot}
```{r export-table}

Committing HTML outputs

Knit HTML files belong in docs/ and should be committed to Git. This gives anyone access to a readable, rendered version of the analysis without needing to re-run the code. Apply the same naming convention as the source .Rmd file.

Keep HTML outputs in sync

If you update an Rmarkdown file significantly, re-knit and commit the updated HTML. A docs/ folder with HTML files that are months behind their source files is more confusing than helpful.

§5 Helper functions

When to write a helper function

If a block of code is longer than roughly 25 lines, or if you find yourself copying the same logic across two scripts, that code belongs in a helper function in the functions/ folder. The test is simple: if you would rather copy the code than write the function, that is exactly the moment to write the function.

Helper functions serve two purposes at once. They keep individual scripts short and readable — a script that calls filter_by_qc() is clearer than one that embeds 40 lines of filtering logic inline. They also create a shared, versioned, improvable tool that others can use rather than each person maintaining their own private version of the same logic.

Anatomy of a good helper function

Document helper functions with Roxygen-style headers. Using Roxygen for helper functions as well as formal packages means the transition to a package, if and when that happens, is seamless. Plain comments above the function are acceptable for quick, narrowly scoped functions, but Roxygen is preferred.

Well-documented helper function
#' Filter cells by QC metrics
#'
#' Applies minimum and maximum feature count thresholds and a
#' mitochondrial percentage threshold to a single-cell object.
#' All thresholds are parameterized so the function can be
#' adapted across datasets without editing the function body.
#'
#' @param obj          A single-cell object.
#' @param min_features Minimum number of features per cell. Default 200.
#' @param max_features Maximum number of features per cell. Default 6000.
#' @param max_mt_pct   Maximum mitochondrial percentage. Default 20.
#'
#' @return A filtered single-cell object.
#' @export
filter_by_qc <- function(obj,
                         min_features = 200,
                         max_features = 6000,
                         max_mt_pct   = 20) {

  # Apply feature count thresholds
  keep <- obj$nFeature > min_features & obj$nFeature < max_features

  # Apply mitochondrial percentage threshold
  keep <- keep & obj$pct_mt < max_mt_pct

  return(obj[, keep])
}

Generalization

Write functions to be useful beyond the immediate analysis. The most important practice is to avoid hardcoding: any numeric threshold, column name, string pattern, or file path that could reasonably vary across datasets should be a function argument with a sensible default. This costs almost nothing at the time of writing and substantially extends the function's useful life.

✓ Parameterized
filter_by_qc(obj, min_features = 200, max_mt_pct = 20) plot_embedding(obj, color_by = "celltype", pt_size = 0.5) export_markers(markers_df, path_out, top_n = 10)
✗ Hardcoded
obj[obj$nFeature > 200 & obj$pct_mt < 20, ] plot(obj, group = "celltype", size = 0.5) write.csv(head(markers, 10), "output/markers.csv")

The case for a shared function library

One recurring problem in computational research is that every team member develops their own private version of the same functions: slightly different, incompatible, and impossible to improve collectively. When a better approach is found, each person has to rediscover it separately.

The functions/ folder, versioned in Git, is the solution to this. When a helper function solves a general problem well, it belongs there — available to everyone, improvable by anyone. When a function in functions/ is updated, everyone benefits from the update immediately. This is how a group builds collective computational infrastructure rather than a collection of parallel silos.

Standard R conventions apply

Use snake_case for function and argument names. Return a single object explicitly with return(). Keep functions focused on one task. If a function is doing two distinct things, it should probably be two functions.

§6 R packages

When to build a package

Not every collection of helper functions needs to become a package. Functions that support a single project can live comfortably in functions/. A package is warranted when the tools are broadly applicable, when they are being used across multiple projects, or when you want formal documentation and testing infrastructure that supports long-term use and public release. NICHES is an example of a tool that earned package status through broad applicability and external demand.

Standards

R packages in this lab follow CRAN standards. This means all functions are documented with structured documentation headers; the package is built, checked, and tested using standard R package development tools; a documentation site is generated and kept current; and a NEWS.md file tracks changes across versions. The package must pass all standard checks with no errors or warnings before it is considered complete.

AI-assisted package development

R packages should be built using Claude Code or a comparable AI coding tool. Package development involves substantial boilerplate — metadata files, namespace management, test scaffolding, documentation site configuration — that AI handles well and that is tedious to write by hand. Using AI for infrastructure frees you to focus on the scientific logic of the functions themselves.

Testing and vignettes

Every package should be tested on both synthetic data and small-scale real-world data before release. Do not release a package whose test coverage consists only of examples you know will pass. Every package should also include at least one demo script showing how to apply it to real in-house data, and at least one vignette knit to HTML that explains the package's purpose and core workflow. A package without a working vignette is a package that no one will be able to use without asking the author for help. All packages are pushed to GitHub under the RaredonLab organization.

§7 Publication-ready code

Publication-ready code is primarily about reproducibility. The standard is: given your scripts, your data paths, and a compatible computing environment, someone else should be able to reproduce every result, figure, and table in your paper without asking you a single question.

Linear execution

Each script should be independently executable. It should load its inputs from disk, operate on them, and save its outputs to disk. A reader should not need to run a different script first to generate objects that this script expects to find in memory.

Non-linear dependencies — where Script B can only run if Script A has been run in the same session — are one of the most common causes of irreproducible analysis. They are easy to introduce and very hard to debug after time has passed. The discipline of loading and saving explicit objects at the boundary of every script eliminates them.

✓ Self-contained
obj <- readRDS(path_input) # ... analysis ... saveRDS(obj, path_output)
✗ Session-dependent
# assumes 'obj' is already in the environment # from a script run earlier this session obj <- run_next_step(obj, ...)

The one-operation principle

Think of each script as a single, well-defined operation in a larger pipeline. Script 01 preprocesses. Script 02 clusters. Script 03 annotates. This structure means any step can be re-run independently, any step can be understood independently, and the full pipeline is legible as a sequence of named stages.

When all the work from a project lives in a single long script, reproducing one figure requires understanding all of it. When work is staged into discrete scripts, a collaborator can go directly to the script that produced the output they care about.

Date stamps and authorship

Every script, every output file, and every figure is stamped with a date and initials. See Document 03 §11 for the full naming convention. A dated, initialed filename combined with a clear script header means any output can be traced back to the code and data that produced it, by anyone, at any time.

§8 Patterns to avoid

The following failure modes are common in research computing. None of them reflect bad intentions — they emerge from working quickly. Being aware of them is the first step to avoiding them.

The monolithic script

A single script that runs an entire multi-stage analysis — from raw data through final figures — is difficult to transfer to another person or return to after six months. Breaking work into staged scripts, each representing one operation, keeps every part of the analysis tractable on its own.

Session-dependent analysis

Writing code that assumes specific objects are already in the environment — because they were created by a script run earlier in the same session — produces analysis that cannot be reproduced without reconstructing the exact session state. Load from disk at the top of each script and save to disk before finishing. The small cost of explicit file I/O at each stage pays for itself the first time you need to rerun a single step.

Private helper functions

Writing a useful function inline in a script, using it a few times, and never formalizing it means that function disappears when the script is archived. It cannot be improved by others and will be reinvented slightly differently by the next person who needs it. If a block of code is worth writing, it is worth putting in functions/.

Hardcoded values

Embedding numeric thresholds, column names, or file paths directly in function bodies rather than as arguments makes those functions impossible to reuse on different data. Parameterize anything that could reasonably vary.

Uncommitted work

Code that is not pushed to GitHub cannot be seen, reviewed, or helped with by anyone else. This applies especially to helper functions: a function that exists only on your laptop is not part of any shared infrastructure. See Document 03 for the full commit and push workflow.

Getting started

If some of these practices are new to you, the best approach is to start with one and build from there. Write a clean header block on your next script. Name your chunks in your next Rmarkdown file. Move the next long block you write into a helper function. Good habits compound over time, and none of them require perfection from the beginning.