Also referred as three-point estimator, it often used in engineering approximations, Values around the most likely value (c) have higher probability of occurrence.
rtriangle(n = 1, a = 0, b = 1, c = (a + b)/2)
Generates n independent samples from a triangular distribution with: a = minimum (lower bound) b = maximum (upper bound) c = mode (peak, most likely value), where a ≤ c ≤ b
dtriangle(x, a, b, c): Density (PDF) Returns the height of the probability (y) density function at point(s) x
This is the symmetric case (mode at midpoint). The peak height is always 2/(b-a) for any triangular distribution (area must integrate to 1). Here b-a=1, so peak PDF = 2.
Code
dtriangle(4, 0, 10, 4) # 2/(b-a) = 2
[1] 0.2
ptriangle(q, a, b, c): Cumulative distribution function (CDF) Returns the probability P(X<=q).
Code
ptriangle(5,0,10,5)
[1] 0.5
qtriangle(p, a, b, c): Quantile function (inverse CDF) Given a probability p (0 ≤ p ≤ 1), returns the value x.
Code
qtriangle(ptriangle(4, 0, 10, 5), 0, 10, 5)
[1] 4
UNIFORM DISTRIBUTION
R provides built-in functions (no extra packages needed):
runif(n, min = 0, max = 1) — Generates n random samples. Example: runif(5, min = 10, max = 20) gives 5 numbers uniformly between 10 and 20.
dunif(x, min = 0, max = 1) — Density (PDF) at x. Example: dunif(0.5, 0, 1) returns 1 (flat height for standard uniform).
punif(q, min = 0, max = 1) — Cumulative probability P(X≤q). Example: punif(0.75, 0, 1) returns 0.75.
qunif(p, min = 0, max = 1) — Quantile (inverse CDF): value where CDF = p. Example: qunif(0.7, 0, 1) returns 0.7.
These functions follow the same naming as rnorm/dnorm/pnorm/qnorm for normal.
Code
set.seed(123) # For reproducibility# Generate 100,000 samples from Uniform(0,1)samples <-runif(100000, 0, 1)mean(samples) # ≈ 0.5
[1] 0.4992992
Code
var(samples) # ≈ 0.0833 (which is 1/12)
[1] 0.08317841
Code
sd(samples) # ≈ 0.2887
[1] 0.2884067
Code
# Densitydunif(0.5, 0, 1) # 1
[1] 1
Code
# CDF and quantile round-trippunif(0.3, -2, 3) # Probability up to 0.3 in Uniform(-2,3) → 0.46
[1] 0.46
Code
qunif(0.46, -2, 3) # ≈ 0.3 (recovers the value)
[1] 0.3
Tornado Analysis Comparison
Approach
Basis
Best For
Captures Distributions?
Handles Interactions?
Example Tools
Correlation (e.g., Spearman)
Monte Carlo ranks
Probabilistic risk (your current)
Yes
Partially
mc2d, @RISK, Crystal Ball
OAT Percentage Change
Deterministic ±%
Quick stakeholder communication
No
No
Excel, deterministic models
OAT Range/Absolute Swing
Low-High extremes
Identifying upside/downside risk
No (but can use dist. extremes)
No
Most tornado tools
Change in Output Statistic
Binned MCS results
Conditional impacts in simulation
Yes
No
@RISK
Regression Coefficients
Linear fit to MCS
Scaled importance
Yes
Limited
@RISK, tornado package (R)
Recommendation
Start with correlation-based: It’s fast and gives a good global ranking (as in your warranty example — CW dominated with ~0.90 Spearman).
Follow up with OAT for the top 2–3 drivers if you need actionable “how much” impact or to communicate upside/downside risk to management.
Many professional analyses (AACE, PMI risk guidelines) use both — correlation for screening, OAT for detailed reporting.
Example 4.2 - Page 113 of Practical Reliability Engineering Textbook
Example calculating the probability of exceeding yield strength, with Monte Carlo method, consider a simle stress analysis problem, where a random force F is applied to a rectangular area with dimensions AxB, Based on the previously recorded data and the goodness of fit criteria, force F can be statistically described by the 2-parameter weibull with shape=2.5 and eta = 11300 N (mean value 10026N),
Dim A has mean of 2.0cm with the tolerance of +/-1.0mm and B has the mean of 3.0cm with the tolerance +/-1.5mm.
The structure is expected of function properly while within the elastic strain range, there force the prob of exceeding the yield strength of 30M Pa needs to be estimated.
Uni axial stress can be calculated as the force divided by the area it is acting on S=F/(A*B), A can be defined by three point estimator (0.019-min, 0.02-most likely, 0.021-max), B as (0.0285, 0.03, 0.0315) meters, the sum of runs is m=1000 iterations
Code
library(ggplot2)library(dplyr)
Attaching package: 'dplyr'
The following objects are masked from 'package:stats':
filter, lag
The following objects are masked from 'package:base':
intersect, setdiff, setequal, union
Code
library(tidyr) # For pivot_longer# Monte Carlo Simulation: Probability of exceeding yield strength (30 MPa)# With added tidy sensitivity tableset.seed(123) # For reproducibilitym <-10000# Number of Monte Carlo iterations# 1. Sample applied force F from 2-parameter Weibull distributionF <-rweibull(m, shape =2.5, scale =11300)# 2. Sample dimensions using triangular distributions (in meters)A <-rtriangle(m, a =0.019, b =0.021, c =0.020) B <-rtriangle(m, a =0.0285, b =0.0315, c =0.030)# 3. Calculate stress S = F / (A * B) in PaS_Pa <- F / (A * B)# 4. Yield strength limityield_strength_Pa <-30e6# 5. Estimated probability (base case)prob_exceed <-mean(S_Pa > yield_strength_Pa)# Resultscat("Monte Carlo Simulation Results (", m, " iterations )\n", sep ="")
Monte Carlo Simulation Results (10000 iterations )
Code
cat("Estimated probability of exceeding 30 MPa yield strength:\n")
Estimated probability of exceeding 30 MPa yield strength:
# Variable with most impact (highest absolute correlation)impacts <-abs(c(corr_SV, corr_CW, corr_NFF, corr_failure))names(impacts) <-c("SV", "CW", "NFF", "Failure")most_impact <-names(impacts)[which.max(impacts)]cat("Variable with most impact:", most_impact, "\n")
Variable with most impact: CW
Code
# Histogram with added vertical lineshist(WCCM, breaks =50, prob =TRUE,main ="Distribution of Warranty Cost (10000 simulations)",xlab ="Cost (M$)", col ="steelblue", border ="white")abline(v =mean(WCCM), col ="red", lwd =2, lty =1) # Mean lineabline(v = upper_80, col ="green", lwd =2, lty =2) # 80% upper bound linelegend("topright", legend =c("Mean", "80% Upper Bound"),col =c("red", "green"), lwd =2, lty =c(1, 2))
The Risk Driver Method (also called risk driver approach or common risk factors) is a best-practice technique in quantitative risk analysis (QRA) for handling correlations and uncertainties in Monte Carlo simulations (MCS) without requiring a full pairwise correlation matrix. It is explicitly recommended in AACE International Recommended Practices (e.g., RP 57R-09 for integrated cost-schedule risk analysis using Monte Carlo on CPM schedules) and aligns with PMI’s Practice Standard for Scheduling and PMBOK Guide emphasis on modeling risks realistically in schedule/cost simulations. Instead of estimating correlations between every pair of tasks, you:
Identify 5–15 common risk drivers (shared risks/events that can affect multiple tasks). Define each driver’s probability of occurring and impact range (e.g., triangular distribution as a multiplier on duration/cost: 1.0–1.5×). Map (assign) the driver to all affected tasks. In MCS: For each iteration, sample if the driver occurs and its impact → apply the same sampled impact to all mapped tasks. This induces natural, realistic correlations.
This method is scalable for real projects with tens/hundreds of tasks and provides sensitivity analysis (tornado charts) showing which drivers contribute most to delay/cost variance.
Real-World Example: Space Vehicle Development Project A classic example (from David Hulett’s Practical Schedule Risk Analysis, widely cited in PMI/AACE contexts and real aerospace/oil & gas projects) uses discrete risk drivers in a schedule risk analysis for a space vehicle development project.
Common Risk Drivers Identified (7 examples, typical in high-tech/NPD projects):
Task C: Software Coding → Affected by “Software bugs”.
Task D: Integration & Test → Affected by “Integration issues”, “Test failure”, “Design change”.
Task E: Assembly → Affected by “Parts delay”, “Funding delay”.
In MCS (e.g., 10,000 iterations):
For “Parts delay”: If it occurs (40% chance), apply the same sampled multiplier (e.g., 1.25×) to both Task B and Task E → creates correlation without manual coefficient guessing. Results: Histogram of project finish dates, P80/P90 confidence levels, and sensitivity showing “Test failure” as top driver (e.g., contributes 35% to variance).
This approach complies with AACE RP 57R-09 (Monte Carlo on CPM with risk drivers for correlations) and PMI’s quantitative techniques (e.g., simulation modeling in PMBOK). In practice, tools like Safran Risk, Primavera Risk Analysis, or @RISK implement this directly. For construction/mining (common AACE examples), drivers often include “Permitting delay”, “Weather impact”, “Labor shortage”, “Site conditions”. This focuses mitigation on high-impact drivers rather than individual tasks.
Code
library(triangle)library(mc2d) # For tornado plot
Loading required package: mvtnorm
Attaching package: 'mc2d'
The following objects are masked from 'package:base':
pmax, pmin
Code
set.seed(123)num_sims <-10000# Base durations for the 5 tasksbase_durations <-c(10, 15, 20, 12, 18) # A, B, C, D, Etask_names <-c("A", "B", "C", "D", "E")# Define the 3 common risk driversdrivers <-list(integration_issues =list(prob =0.60, min =1.20, mode =1.35, max =1.50),parts_delay =list(prob =0.40, min =1.10, mode =1.15, max =1.30),test_failure =list(prob =0.50, min =1.30, mode =1.50, max =1.80),software_bugs =list(prob=0.7, min=1.15, mode=1.275, max=1.40),funding_delay =list(prob=0.2, min=1.10, mode=1.15, max =1.20),design_change =list(prob=0.3, min=1.20, mode=1.40,max =1.60),vendor_quality =list(prob=0.35, min=1.15, mode=1.30, max=1.45) )# Mapping: which tasks are affectedmapping <-list(A ="design_change",B =c("parts_delay","vendor_quality"),C ="software_bugs",D =c("integration_issues","test_failure","design_change"),E =c("parts_delay","funding_delay"))# Affected matrixaffected_matrix <-matrix(0, nrow =5, ncol =7)colnames(affected_matrix) <-names(drivers)rownames(affected_matrix) <- task_namesfor (task in task_names) {for (drv in mapping[[task]]) { affected_matrix[task, drv] <-1 }}# Storage for impacts and total durationstotal_durations <-numeric(num_sims)impact_matrix <-matrix(1, nrow = num_sims, ncol =7)colnames(impact_matrix) <-names(drivers)# Main Monte Carlo simulationtotal_durations <-replicate(num_sims, { current_durations <- base_durationsfor (j inseq_along(drivers)) { driver <- drivers[[j]] driver_name <-names(drivers)[j]if (runif(1) < driver$prob) { impact <-rtriangle(n=1, a = driver$min,b = driver$max, c = driver$mode) affected <- affected_matrix[, driver_name] ==1 current_durations[affected] <- current_durations[affected] * impact } }sum(current_durations)})# Wait — the above has a problem with impact_matrix in replicate (i not defined).# Better to use a for loop for impact storage:total_durations <-numeric(num_sims)for (i in1:num_sims) { current_durations <- base_durationsfor (j inseq_along(drivers)) { driver <- drivers[[j]] driver_name <-names(drivers)[j]if (runif(1) < driver$prob) { impact <-rtriangle(1, a = driver$min, b = driver$max, c = driver$mode) impact_matrix[i, j] <- impact affected <- affected_matrix[, driver_name] ==1 current_durations[affected] <- current_durations[affected] * impact } } total_durations[i] <-sum(current_durations)}# === ALL ORIGINAL OUTPUTS ===cat("Base (deterministic) duration:", sum(base_durations), "\n")
Base (deterministic) duration: 75
Code
cat("Monte Carlo results (", num_sims, " simulations):\n", sep ="")
Monte Carlo results (10000 simulations):
Code
cat("Mean total duration: ", round(mean(total_durations), 2), "\n")
cat("The number of Monte Carlo runs needed for 5% accuracy at 95% confidence:", round((1.645* (mean(total_durations)/sd(total_durations)) /0.05)^2), "\n")
The number of Monte Carlo runs needed for 5% accuracy at 95% confidence: 109471
Code
# histogramhist(total_durations, breaks =50, main ="Distribution of Total Project Duration",xlab ="Total Duration (days/weeks)", col ="steelblue", border ="white")abline(v =quantile(total_durations, c(0.80, 0.90)), col =c("orange", "red"), lwd =2, lty =2)legend("topright", legend =c("P80", "P90"), col =c("orange", "red"), lwd =2, lty =2)
Code
# === TIDY PERCENTILE TABLE WITH EXCEEDANCE PROBABILITY ===percentiles <-c(50, 80, 90, 95, 99) # Common project risk percentilesperc_values <-quantile(total_durations, probs = percentiles/100)exceed_prob <-1- percentiles/100perc_table <-data.frame(Percentile =paste0("P", percentiles),Duration =round(perc_values, 2),`Exceedance Probability`=paste0(round(exceed_prob *100, 1), "%"))cat("\nKey Percentiles and Exceedance Probabilities:\n")
# === Using mc2d for Correlation-Based Tornado Plot (FINAL VERIFIED VERSION) ===cat("\n=== Using mc2d for Correlation-Based Tornado Plot ===\n")
=== Using mc2d for Correlation-Based Tornado Plot ===
Code
# Reshape impacts and assign names BEFORE creating mcdataimpact_array <-array(impact_matrix, dim =c(num_sims, 1, 7))dimnames(impact_array) <-list(NULL, # variability dimension (simulations)NULL, # uncertainty dimension (none)c("Integration_Issues", "Parts_Delay", "Test_Failure","software_bugs","funding_delay","design_change","vendor_quality"))# Reshape outputtotal_array <-array(total_durations, dim =c(num_sims, 1, 1))dimnames(total_array) <-list(NULL, NULL, "Total_Duration")# Create mcdata objects (specify dimensions explicitly)mc_impacts <-mcdata(impact_array, type ="V", nsv = num_sims, nsu =1, nvariates =7)mc_output <-mcdata(total_array, type ="V", nsv = num_sims, nsu =1, nvariates =1)# Combine into mc objectmc_combined <-mc(mc_impacts, mc_output)# Combine: mc_impacts (multi-variate) first, then outputmc_combined <-mc(mc_impacts, mc_output)# Compute tornado: output is now at position 2tor <- mc2d::tornado(mc_combined,output =2, method ="spearman")# You called mc(mc_impacts, mc_output), so:# # Position 1: mc_impacts (multi-variate node with your drivers).# Position 2: mc_output (the single output node, "Total_Duration").# # Thus, output = 2 is correct for tornado(mc_combined, output = 2).# Print correlations — now with proper driver names!cat("Spearman Rank Correlations with Total Duration:\n")
Expected (average) project duration across 10,000 scenarios. Risks add ~15.38 units on average to the base 75.
Standard deviation
9.27
Measure of uncertainty. About 68% of outcomes fall between ~80–100 (mean ±1 SD); 95% between ~70–111 (±2 SD). Higher SD reflects correlated risks amplifying variability.
5th percentile (P05)
79.8004523
In the best 5% of scenarios (very lucky), duration is exactly the base 75 — meaning almost no major risks materialized.
50th percentile (median)
92.22048
Most realistic single-point estimate. 50% chance of finishing in 89 or less; 50% chance of longer. Better than the mean for planning (less skewed by extremes).
95th percentile (P95)
110.7045078
In the worst 5% of scenarios, duration reaches ~108. Provides a realistic “worst-case” for high-confidence planning.
P80 contingency reserve
8.3075307
To have an 80% probability of completing on or before the target, add 8.3075307 units to the median. Common target in many organizations (balances risk and cost).
Practical Project Management Insights
Base estimate (75) is overly optimistic — it assumes no risks occur. Using it without contingency has ~50% chance of overrun. Recommended schedule target: 100–101 (median + P80 contingency) → gives 80% confidence of meeting the date. Risk exposure: The ~33-unit spread between P05 (75) and P95 (108) shows significant uncertainty driven by shared risks (correlations via drivers). If you need higher confidence (e.g., 90%): Add ~17–18 units (P90 ≈ 106–107). Management reserve: The difference between mean (90.38) and higher percentiles reflects potential escalation — useful for executive reporting.
Summary Recommendation
Communicate to stakeholders: “Our most likely duration is ~89 units, but with an 80% confidence level, we should plan for 100–101 units to account for integration, parts, and test risks.” This probabilistic view (enabled by the risk driver method) is far more robust than deterministic (single-point) planning and fully aligns with PMI and AACE best practices for quantitative schedule risk analysis.
One-Way Sensitivity Analysis (OAT) for Top 2 Drivers
To provide actionable “how much” insight, we perform OAT on these top 2:
For each driver, we force it to occur (prob = 1) and fix its impact multiplier at the minimum (low scenario) or maximum (high scenario) of its triangular range. All other drivers follow their normal probability and distribution. Output metric: Change in mean total duration relative to base (~90–92 from your simulations).
# OAT Tornado Plotoat_results$Driver <-factor(oat_results$Driver, levels =rev(top_drivers)) # design_change on topggplot(oat_results, aes(x = Driver, y = Delta, fill = Scenario)) +geom_bar(stat ="identity", position ="identity") +coord_flip() +labs(title ="One-Way Sensitivity Tornado Plot\n(Top 2 Risk Drivers)",x ="Risk Driver",y ="Change in Mean Total Duration (from base ~90–92)") +scale_fill_manual(values =c("Low (min impact, forced)"="skyblue", "High (max impact, forced)"="salmon"),name ="Scenario") +theme_minimal(base_size =12) +theme(legend.position ="bottom")
Design_change in its high/forces worst case extends the project by ~13–14 days on average (to ~107 days mean) — confirming it as the top priority for mitigation (e.g., better change control processes).This is a significant upside risk (~15% longer project on average when this driver hits its extreme).