Skip to content

Repository files navigation

Groma

CPT-based liquefaction triggering, in a browser tab, with nothing installed and nothing uploaded.

https://bell-kevin.github.io/groma/dist/

Implements Boulanger & Idriss (2014), CPT and SPT based liquefaction triggering procedures, Report No. UCD/CGM-14/01, Center for Geotechnical Modeling, University of California, Davis (April 2014). Paste a sounding, get factor of safety against depth, reconsolidation settlement, LPI and LSN, with every intermediate value exposed and every validity boundary flagged.

Written in Rust, compiled to WebAssembly. Being written in Rust is not why it is fast, and the language buys you nothing here over JavaScript. See Load cost, where that is measured rather than asserted.


What it does

  • Full B&I 2014 CPT triggering: r_d, C_N, K_σ, MSF, the equivalent clean sand adjustment, CRR, CSR, factor of safety.
  • Probability of liquefaction from the report's own closed-form probabilistic relationship (Eq. 5.25/5.26), not a Monte Carlo simulation over invented input distributions. See Why there is no Monte Carlo.
  • Post-liquefaction reconsolidation settlement (Zhang, Robertson & Brachman 2002), LPI (Iwasaki et al. 1978) and LSN (van Ballegooy et al. 2014).
  • A deterministic parametric sweep over peak ground acceleration and water table depth. Every cell is a complete, reproducible run: you can point at any point on the surface and defend it.
  • Every intermediate a practitioner would check by hand, in a table and in the CSV export.
  • Validity warnings owned by the engine, each citing its source.
  • CSV, JSON and printable-summary export, all carrying method, edition, app version, timestamp, every input and every warning.
  • Share links and local persistence, both encoded entirely in Rust.

What it is not

  • Not certified or approved by anybody. Not by ASCE, ASTM, ISSMGE, NZGS, Caltrans, WSDOT, any state DOT, any building authority, or any regulator. No trade body has reviewed it.
  • I do not know whether output from this is acceptable for submission anywhere. That depends on your jurisdiction, your reviewer and your professional judgement. Ask before you rely on it.
  • Not a substitute for engineering judgement, and not a substitute for the site-specific sampling the fines-content correlation needs.
  • No lateral spreading, no residual strength, no site response analysis, no SPT or V_s input, no multi-sounding batch processing.
  • No Monte Carlo propagation of input uncertainty. That is deliberate; see below.

How this compares

Verified by searching in August 2026. If you are reading this much later, re-check it — this table will age.

The free options are listed first because they are the ones that matter.

Tool Cost Where it beats Groma
liquepy (Python, open source) Free This is the closest free equivalent and in several ways the better tool. It implements B&I 2014 triggering, settlement, site response and element tests, reads NZGD data, and composes with the whole scientific Python stack. If you write Python, use this rather than Groma. Groma adds only a browser interface, the sweep, and export with provenance.
WSliq (Steven Kramer, Univ. of Washington / WSDOT) Free Windows GUI from a leading authority, funded by a state DOT, with a bundled case-history database and performance-based (rather than only deterministic) hazard analysis. Broader in scope than Groma. Caveat: still labelled a beta, with a version 1.0 planned for June 2009 that did not arrive, and its methods predate B&I 2014.
LiquPy, PYLIQ, GEOLib-Plus (Deltares) Free Scriptable, batchable, integrable. GEOLib-Plus in particular has far better CPT file-format support (GEF and XML) than Groma's paste-a-CSV.
Settle3 (Rocscience) $1,349/yr personal lease, $3,199/yr floating Your employer may already license this. If it is on your machine, use it. It does SPT, CPT and V_s, integrates with a full settlement and consolidation suite, and its results are routinely accepted by reviewers who have never heard of Groma.
CLiq / CPeT-IT (GeoLogismiki) €449 each; bundles €638–849 Purpose-built CPT interpretation, many more correlations, proper multi-sounding project handling, report generation, direct rig-format import.
NovoCPT, LiqIT, Liquiter (GeoStru) Paid / subscription Established, supported, and used in practice. GeoStru's Liquiter is browser-based like Groma but the B&I 2014 method sits behind a subscription.
reuven.tools liquefaction Free, no login Simpler and quicker if a single-point SPT check is genuinely all you need.

The honest summary: if you write Python, liquepy is the better tool and this project is largely redundant for you. If your firm licenses Settle3 and you have a seat, use Settle3. Groma is for the case where neither is true — where the method is public, the engine exists only as a library in a language you do not write, the licence is a recurring four-figure sum that is not on your desk, and the sounding is client data you would rather not upload.


Verification

The engine reproduces 213 CPT case histories from the database published in B&I 2014 Section 3. For each case the report tabulates its own intermediate values; Groma recomputes each from the same inputs. Deviations, relative:

Quantity Mean Worst
I_c 0.150% 1.320%
C_N 0.234% 0.811%
q_c1N 0.172% 0.592%
Δq_c1N (scaled by q_c1Ncs) 0.155% 1.717%
q_c1Ncs 0.017% 0.183%
r_d 0.260% 0.671%
MSF 0.245% 0.496%
K_σ 0.209% 0.544%
CSR (M 7.5, 1 atm) 0.666% 2.945%
q_c1Ncs, end-to-end coupled solve 0.232% 1.613%

The report tabulates to two or three significant figures, so roughly 0.2% is the floor of what is checkable: a C_N printed as 1.67 carries 0.3% of ambiguity before any arithmetic happens. Reproduce with:

cargo test -p groma-core --test case_histories -- --nocapture

core/tests/data/published_cases.rs is generated from the report's own tables. Rows are filtered only against identities and bounds stated within the report (K_σ ≤ 1.1, q_c1N = C_N · q_cN), never against this implementation, so the check is not circular.

Test suite: 179 tests. 153 unit, 4 case-history, 14 property (proptest), 8 browser-boundary (wasm-bindgen-test in headless Chrome).

Two deliberate departures from a literal transcription

Eq. 2.18 rather than Eq. 2.19 for MSF. The report presents Eq. 2.19 as Eq. 2.18 "with the fixed terms expressed numerically", but its constants are rounded: the exact values are 8.6362 and 1.32441, printed as 8.64 and 1.325. The rounding costs up to 6×10⁻⁴ in MSF and means Eq. 2.19 does not return exactly 1 at M = 7.5, which its own normalisation requires. Groma uses the exact form. msf_eq_2_19 is retained so the difference can be measured.

A monotonicity cap on the Zhang volumetric strain curves. Taken literally, Zhang et al. Appendix A has the FS = 0.6 curve cross above the FS = 0.5 curve near q_c1Ncs ≈ 151, because branch [A3] overtakes [A1] just past their q = 147 breakpoint. That gives more strain at a higher factor of safety, which is backwards; the curves in the paper's Figure 3 do not cross. Each level is capped at the next lower level's value, which reproduces Appendix A exactly everywhere the fits are self-consistent and clips only inside that narrow band. The published branch pairs also step by up to 6% at their breakpoints (worst at FS = 0.9, q = 60), which is about 2 mm of settlement per metre of layer and is left alone.

Why there is no Monte Carlo

B&I 2014 is already a probabilistic formulation. The "deterministic" curve is the C_o = 2.80 curve, which the report states is one standard deviation (σ_ln(R) = 0.20) below the median C_o = 2.60 curve and corresponds to P_L ≈ 16%. Probability of triggering is therefore a published closed-form substitution, not a sampling problem, and that is what Groma computes.

Monte Carlo would propagate input uncertainty — fines content, water table, a_max, spatial correlation. Groma does not, for two reasons. There is nothing published to check such a layer against, because the input distributions are not published and the vertical spatial-correlation structure is an open research question. And a percentile built on distributions the tool invented is harder to defend to a reviewer than a grid of fully traceable deterministic runs, which is what the sweep provides instead.


Load cost

Measured on the committed build:

bytes gzipped
rustc output, before wasm-bindgen 1,361,270
.wasm after wasm-opt -Oz 293,904 134,845
wasm-bindgen JS glue 36,038 6,861
CSS 9,249 2,570
index.html 1,740 1,013
Total 340,931 145,289

About 142 KB gzipped. On a 1 Mbit/s connection that is roughly 1.2 seconds of download before the first pixel, on top of a cold TLS handshake. A JavaScript implementation of the same app would be a fraction of that.

The sweep was 37× slower than it needed to be, and the language was never the problem

The brief for this project said not to sell "written in Rust" as a user-facing benefit without a benchmark. Here is the benchmark, and it does not flatter the earlier version of this section.

An earlier revision of this README reported that the wasm sweep took 451 ms against 209 ms for bench/kernel.js, and blamed the 2.2× gap on wasm having no host Math.pow. The measurement was real. The diagnosis was wrong. The sweep was not pow-bound because wasm's pow is slow; it was pow-bound because it called pow roughly forty times more often than the calculation requires.

a_max enters the whole B&I procedure in exactly one place, the cyclic stress ratio of Eq. 5.2. Nothing else — not the coupled I_c solve, not the equivalent clean sand fixed point, not CRR, MSF, K_σ, r_d or the Zhang strain curves — depends on it. The application sweeps acceleration against water table on a 40 × 40 grid, so 39 of every 40 cells were re-deriving a resistance profile that could not have changed. On top of that, the Zhang monotone cap re-derived every lower strain level on each call, so a single volumetric strain cost up to twenty-one powf calls, repeated for every cell.

The engine now solves the resistance side once per water-table row (analysis::DepthResistance) and builds the ten Zhang levels in one descent (settlement::strain_table). Same 40 × 40 grid, same 200-reading sounding, timed in the same process, nine interleaved rounds:

best median
wasm, before 501.8 ms 543.4 ms
wasm, after 13.5 ms 14.0 ms
JavaScript, bench/kernel.js 461.4 ms 611.5 ms
JavaScript, same hoisting applied 14.0 ms 15.2 ms

37× faster than it was, and 34× faster than the JavaScript this repository ships to compare against. Every cell is bit-identical to a standalone analyse at the same inputs, which the_row_path_reproduces_a_per_cell_analysis_bit_for_bit asserts with no tolerance at all across five axis combinations.

But read the last row. A JavaScript port with the same hoisting lands at 14.0 ms against wasm's 13.5 ms. That is a rounding error, not a win. The 2.2× this README used to report was never a property of the language: it was a property of an algorithm that called pow upwards of twelve million times per sweep — 13.1 million in bench/kernel.js, counted by instrumenting Math — and at that call density the benchmark measures the engine's Math.pow and nothing else. Remove the redundant calls and the gap goes with them. There is no language-level performance argument to make here, in either direction, and this README will not invent one now that the numbers happen to point the other way.

Isolated transcendentals, 2 million calls each, confirm there is no headroom left to win: wasm powf 64.1 ms against V8's Math.pow 60.2 ms — parity.

Reproduce:

cargo test -p groma-app --target wasm32-unknown-unknown --test bench --release -- --nocapture
node bench/run.mjs /path/to/chrome

Measurement caveat. The table above was produced under Node 22's V8, not the Chrome 151 build the rest of this README uses, because no Chrome was available on the machine that made the change. The wasm figures agree closely across the two (501.8 ms here against 451 ms there for the same committed build); the JavaScript ones do not, because Node 22's Math.pow is roughly three times slower than Chrome 151's. The wasm numbers therefore transfer and the JavaScript ones do not, and both columns should be re-run under Chrome 151 before being quoted anywhere else. The conclusion that survives either engine is the 37× — that one is a change in how much arithmetic gets done, not in how fast it runs.

So why Rust? Because the author chose it, and because the type system and the lint configuration (forbid(unsafe_code), deny(unwrap_used, panic, indexing_slicing), typed errors on every fallible path) make the source harder to get wrong. That is a property of the code, not an experience the user has. It is not a performance argument, and this README will not pretend otherwise.


Confidentiality

Verified in a real browser, not asserted.

  • The computation runs entirely in your browser. After the initial load of index.html, the .wasm, the JS glue and the CSS, the application makes zero network requests. Confirmed by driving the app through a full session in headless Chrome with request interception: 4 requests at load, 0 afterwards, across input changes, exports, share-link generation and error recovery. The build also fails if XMLHttpRequest, WebSocket, EventSource or sendBeacon appears in the shipped glue, or if any fetch appears other than the one wasm-bindgen's own loader uses to pull the .wasm module from the same origin — that call is how the module boots and is one of the four requests counted above.
  • Your sounding is never uploaded. There is no server, no account, no telemetry and no analytics.
  • What persists locally: the current state — your sounding and all inputs — is written to localStorage under the key groma.state.v1, in the same compressed encoding used for share links. "Clear stored copy" removes it. Nothing else is stored.
  • Share links carry your data in the URL fragment. A fragment is not sent to a server in an HTTP request, but a URL can be pasted anywhere. A share link contains the whole sounding. Treat it as you would the file.

What has not been tested

Verification was done on one Windows machine against Chrome 151. These are the gaps, stated rather than glossed:

  • Only one browser. Everything was verified in Chromium/Chrome 151. No Firefox, no Safari, no mobile. Safari is the one to watch: it throws on localStorage access in private browsing, which platform::save handles by returning false, but that path has never actually executed on Safari.
  • Only one machine, and a fast one. The 690 ms load and the 14 ms sweep are from localhost on a desktop. Cold-load time over a real 3G or congested hotel connection has not been measured, and neither has the sweep on a five-year-old phone. The sweep is still synchronous and still blocks the main thread for however long it takes on the target device; at 14 ms that is no longer a dropped frame on this machine, but a phone an order of magnitude slower would still be visibly stalled, and that has not been measured either.
  • The browser-boundary tests are failing in CI. test and lint passes — the format check, both clippy passes, the 213 published cases and the property tests all run green on Actions. The browser boundary tests job does not: Chrome and wasm-bindgen-cli install correctly and the test binary then fails under wasm-bindgen-test-runner. The eight assertions in app/tests/boundary.rs cover localStorage, the URL fragment and the download chain, so until that job is green those three paths are covered by code review only. Nothing in core/ is affected.
  • GitHub Pages at a subpath. public_url = "./" is verified from a server root and from the live Pages subpath: all four assets resolve, and the .wasm is served as application/wasm.
  • Downloads were verified as far as the browser accepts them. The test harness confirms the Blob, object URL, anchor and revoke chain completes and captures the file contents; it does not confirm a file landed in a Downloads folder under a corporate download policy.
  • No large real sounding. The largest profile tested is 5,000 synthetic readings in a parser test and 1,000 in a share-link test. No actual rig export from a real CPT has been through it, so real-world format quirks — unit columns, metadata preambles, negative u2 — are unproven.

What to check first after deploying

  1. Open it in Safari and in Firefox.
  2. Open it on a phone, with the sweep enabled, and see how long it locks up.
  3. Get the browser boundary tests job green.
  4. Load a real CPT export from your own rig software and see what the parser does with it.

Building

npm install && npm run dev will not work; this is a Rust project.

# 1. Toolchain. rust-toolchain.toml pins the version and target, so this is
#    usually all rustup needs.
rustup toolchain install 1.97.1
rustup target add wasm32-unknown-unknown

# 2. Trunk.
cargo install trunk --version 0.21.14
#    or download a prebuilt binary from
#    https://github.com/trunk-rs/trunk/releases

# 3. Run it.
cd app
trunk serve            # http://localhost:8080
trunk build --release  # writes ../dist/

Tests:

cargo test -p groma-core                       # 171 native tests
cargo clippy -p groma-core --all-targets -- -D warnings
cargo clippy -p groma-app --target wasm32-unknown-unknown -- -D warnings
cargo fmt --check

# Browser-boundary tests need a browser and a matching driver.
cargo install wasm-bindgen-cli --version 0.2.126
CHROMEDRIVER=/path/to/chromedriver \
  cargo test -p groma-app --target wasm32-unknown-unknown --test boundary

Windows, x86_64-pc-windows-gnu

Two things will bite you:

  1. error: error calling dlltool 'dlltool.exe': program not found. leptos_hot_reload pulls in windows-sys, which needs dlltool on the GNU toolchain. The copy rustup ships in lib/rustlib/x86_64-pc-windows-gnu/bin/self-contained is incomplete — it has no assembler and fails with "dlltool could not create import library". Put a full mingw-w64 binutils on PATH (for example MSYS2's mingw64/bin), or use the MSVC toolchain.
  2. Trunk rejects NO_COLOR=1. It wants true or false. Unset it or set NO_COLOR=true.

Deployment

GitHub Pages is the primary deployment, built and published by .github/workflows/build.yml. The workflow runs the full test suite, both clippy passes, the format check, builds with Trunk, reports the bundle size in the job summary, fails if a network API appears in the shipped glue, and commits dist/.

dist/ is committed deliberately, so that Pages serves the bundle directly with no build step in the serving path. public_url = "./" in app/Trunk.toml keeps every asset reference relative, which is what lets the same dist/ work from a project subpath and from a local file server without rebuilding.


Structure

core/   Pure Rust. No wasm-bindgen, no web-sys, no JS, and zero
        dependencies — not serde, not thiserror, not num-traits. Every
        equation, the solver, settlement, the sweep, parsing, export, the
        share encoding, and the chart geometry. Runs under `cargo test` at
        native speed.
app/    Leptos CSR. Owns the DOM, the SVG and the browser boundary, and
        decides nothing.
bench/  A JavaScript port of the sweep kernel, for the benchmark above.
        Never shipped.

core/ has no dependencies because everything there ships in the .wasm. erf, erfc, the normal CDF and its inverse, LEB128 varints, LZSS compression and base64url are all implemented in core/src/. Dev-dependencies are unrestricted, since they never enter the bundle.


Sources and licences

Triggering Boulanger, R.W. & Idriss, I.M. (2014). CPT and SPT based liquefaction triggering procedures. Report No. UCD/CGM-14/01, Center for Geotechnical Modeling, UC Davis. PDF
Stress exponent Robertson, P.K. (2009). Interpretation of cone penetration tests — a unified approach. Canadian Geotechnical Journal 46(11), 1337–1355. PDF
Settlement Zhang, G., Robertson, P.K. & Brachman, R.W.I. (2002). Estimating liquefaction-induced ground settlements from CPT for level ground. Canadian Geotechnical Journal 39(5), 1168–1180. PDF
LPI Iwasaki, T. et al. (1978).
LSN van Ballegooy, S. et al. (2014). Thresholds after Tonkin & Taylor (2013), as reported by Maurer et al. (2015).

The case-history values in core/tests/data/ are quoted from the B&I 2014 report for verification purposes. The report is distributed publicly by UC Davis; it is not redistributed here, only the tabulated numbers needed to check the implementation.

Licence

AGPL-3.0-only. See LICENSE. If you run a modified version of this as a network service, you must offer its source to your users.


Comparison table and competitor research verified August 2026. Bundle sizes, benchmarks and verification deviations measured on the committed build.

https://bell-kevin.github.io/groma/dist/

back to top

About

CPT-based liquefaction triggering, in a browser tab, with nothing installed and nothing uploaded. Implements Boulanger & Idriss (2014), CPT and SPT based liquefaction triggering procedures. Paste a sounding, get factor of safety against depth, reconsolidation settlement, LPI and LSN, with every intermediate value exposed & every validity boundary

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages