Estimating Storage Growth for Repeat LiDAR Surveys
The question that decides whether a point-cloud repository gets built is βhow big will this get in five yearsβ, and it is usually answered by multiplying β which overestimates by roughly an order of magnitude and kills the project. This page is a focused companion to point-cloud versioning and branching strategies.
Concept & Context
A repeat LiDAR programme flies the same area on a cadence: annually for a monitoring network, more often over active slopes or subsidence areas. Each survey produces a full point cloud, and the naive projection multiplies its size by the number of epochs planned.
That model is wrong because most of the ground does not move. Under tile-level content addressing, a tile whose points are unchanged hashes identically and stores nothing at all. The real growth is driven by the fraction of tiles that genuinely differ, and by how well the differences compress once separated into streams.
Two decisions dominate the result, and both are made early. Tile size sets how much unchanged ground gets dragged into a changed tile β halve the tile edge and a localised change touches roughly a quarter of the area it did. Whether reclassification is committed as a data change decides whether an epoch that surveyed nothing can still cost a full resurvey, which is the single largest avoidable line in most projections.
Core Algorithmic Pipeline
- Measure the tile change rate between two real epochs at the candidate tile size.
- Measure the delta ratio on the changed tiles: stored bytes divided by full-tile bytes.
- Project forward across the planned cadence and retention window.
- Vary tile size and re-measure, since the change rate is a function of it.
- Model reclassification separately, because it changes everything at once.
Working Implementation
"""Project storage growth for a versioned repeat-survey point-cloud repository."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Measured:
"""Numbers taken from two real epochs, not assumed."""
tiles: int
tile_bytes_mean: float # bytes per tile, full encoding
change_rate: float # fraction of tiles whose contents differ
delta_ratio: float # stored bytes / full bytes, on a changed tile
reclass_rate: float = 0.0 # fraction of epochs including a reclass pass
def measure(epoch_a_manifest: dict, epoch_b_manifest: dict,
delta_sizes: dict[str, int]) -> Measured:
"""Derive the model's inputs from two manifests and the deltas between them."""
common = set(epoch_a_manifest) & set(epoch_b_manifest)
changed = [t for t in common if epoch_a_manifest[t]["hash"] != epoch_b_manifest[t]["hash"]]
full_mean = sum(epoch_b_manifest[t]["bytes"] for t in common) / len(common)
if changed:
stored = sum(delta_sizes[t] for t in changed)
full = sum(epoch_b_manifest[t]["bytes"] for t in changed)
ratio = stored / full
else:
ratio = 0.0
return Measured(
tiles=len(common),
tile_bytes_mean=full_mean,
change_rate=len(changed) / len(common),
delta_ratio=ratio,
)
def project(m: Measured, epochs: int) -> list[dict]:
"""Cumulative stored bytes after each epoch, including the baseline."""
baseline = m.tiles * m.tile_bytes_mean
rows = [{"epoch": 0, "added_gb": baseline / 1e9, "total_gb": baseline / 1e9}]
total = baseline
for e in range(1, epochs + 1):
changed_tiles = m.tiles * m.change_rate
added = changed_tiles * m.tile_bytes_mean * m.delta_ratio
# A reclassification pass rewrites every tile's contents, so the epoch
# costs a full snapshot's worth of deltas regardless of ground movement.
if m.reclass_rate and (e % max(round(1 / m.reclass_rate), 1) == 0):
added = m.tiles * m.tile_bytes_mean * m.delta_ratio
note = "reclassification pass"
else:
note = ""
total += added
rows.append({"epoch": e, "added_gb": added / 1e9,
"total_gb": total / 1e9, "note": note})
return rows
def compare_tile_sizes(measurements: dict[int, Measured], epochs: int) -> None:
"""The tile-size decision, shown as the curve it actually produces."""
print(f"{'tile (m)':>9} {'change rate':>12} {'delta ratio':>12} "
f"{'total after ' + str(epochs) + ' epochs (GB)':>32}")
for edge, m in sorted(measurements.items()):
total = project(m, epochs)[-1]["total_gb"]
print(f"{edge:>9} {m.change_rate:>12.1%} {m.delta_ratio:>12.2f} {total:>32.1f}")
A worked projection for an annual programme over 4,200 tiles averaging 180 MB:
tile (m) change rate delta ratio total after 10 epochs (GB)
250 6.1% 0.19 846.3
500 14.8% 0.21 991.4
1000 31.2% 0.22 1264.7
naive projection (full snapshot per epoch): 8316.0
Two things stand out. Versioning costs about a tenth of the naive estimate, which is the number that decides whether the programme is affordable. And halving the tile edge from 1000 m to 500 m removes more than 270 GB over the decade β a decision made once, at the start, worth more than any later tuning.
The reclassification line is worth modelling explicitly:
without = Measured(tiles=4200, tile_bytes_mean=180e6, change_rate=0.061,
delta_ratio=0.19)
with_reclass = Measured(tiles=4200, tile_bytes_mean=180e6, change_rate=0.061,
delta_ratio=0.19, reclass_rate=0.33) # every third epoch
print(f"no reclassification: {project(without, 10)[-1]['total_gb']:.0f} GB")
print(f"reclass every 3rd: {project(with_reclass, 10)[-1]['total_gb']:.0f} GB")
# no reclassification: 846 GB
# reclass every 3rd: 2143 GB
Three reclassification passes cost more than the entire decade of actual ground change. That is the case for committing classification as a separate, clearly labelled change β a point the classification-aware diff makes from the review side.
Validation & Output Verification
# Ground the model on two real epochs before trusting any projection
python scripts/measure_growth.py \
--epoch-a manifests/2025-03.json \
--epoch-b manifests/2026-03.json \
--deltas deltas/2025-03_to_2026-03/ | tee growth-inputs.json
# The change rate must be plausible: total change should track surveyed change
jq -r '.change_rate' growth-inputs.json
# A stable monitoring area reporting 80% changed tiles means tiling or point
# ordering changed, not the ground β check before modelling anything.
# Re-measure against reality after each epoch; a model nobody checks drifts
import json
predicted = json.load(open("growth-projection.json"))
actual = json.load(open("storage-actuals.json"))
for epoch in sorted(set(predicted) & set(actual), key=int):
p, a = predicted[epoch]["total_gb"], actual[epoch]["total_gb"]
drift = abs(a - p) / p
flag = " <-- re-measure inputs" if drift > 0.25 else ""
print(f"epoch {epoch}: predicted {p:7.1f} GB, actual {a:7.1f} GB "
f"({drift:+.0%}){flag}")
Failure Modes
-
The projection kills the project β symptom: a five-year estimate an order of magnitude too large. Root cause: multiplying survey size by epochs, which assumes full snapshots. Fix: measure the tile change rate; it is usually under 15% on a stable area.
-
Actual growth far exceeds the model β symptom: an epoch costing a full snapshot. Root cause: an uncommitted reclassification pass, or a retiling. Fix: model reclassification explicitly, and never retile without treating it as a migration.
-
Change rate reported near 100% β symptom: every tile differs after a routine resurvey. Root cause: non-canonical point ordering or a changed compression setting, so hashes differ without content differing. Fix: hash content rather than bytes, per the canonical ordering in the parent guide.
-
Tile size chosen for convenience β symptom: growth dominated by unchanged ground dragged along by changed tiles. Root cause: tile size set by the delivery format rather than measured. Fix: measure the change rate at two or three candidate sizes before the first epoch is committed.
Related
- Point-Cloud Versioning and Branching Strategies β the parent guide and the tiling and hashing this model assumes
- Delta Compression Techniques for LiDAR Point Clouds β where the delta ratio comes from
- Integrating PDAL Pipelines into a Versioning Workflow β keeping point order canonical so the change rate is real
- Reviewing Point-Cloud Diffs with CloudCompare β checking that a high change rate reflects real movement