From b66072ff55caf34292bafaefbab48456d015cc2c Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Tue, 14 Apr 2026 20:07:25 +0530 Subject: [PATCH 1/7] feat: add Parquet output format for layerstats --- .../com/onthegomap/planetiler/Planetiler.java | 5 +- .../planetiler/archive/TileArchiveWriter.java | 26 ++-- .../archive/TileEncodingResult.java | 12 +- .../planetiler/config/PlanetilerConfig.java | 3 + .../planetiler/util/TileSizeStats.java | 143 ++++++++++++++++-- .../planetiler/util/TileSizeStatsTest.java | 39 +++++ 6 files changed, 203 insertions(+), 25 deletions(-) diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java index 56ee3dcb97..44ee8ef763 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java @@ -835,8 +835,9 @@ public void run() { } Path layerStatsPath = arguments.file("layer_stats", "layer stats output path", - // default to .layerstats.tsv.gz - TileSizeStats.getDefaultLayerstatsPath(Optional.ofNullable(output.getLocalPath()).orElse(Path.of("output")))); + // default to .layerstats.tsv.gz or .layerstats.parquet based on format + TileSizeStats.getDefaultLayerstatsPath(Optional.ofNullable(output.getLocalPath()).orElse(Path.of("output")), + config.layerstatsFormat())); if (config.tileWriteThreads() < 1) { throw new IllegalArgumentException("require tile_write_threads >= 1"); diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java index 0c929b6a32..bc80850c6a 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java @@ -176,7 +176,8 @@ public static void writeOutput(FeatureGroup features, WriteableTileArchive outpu if (config.outputLayerStats()) { layerStatsBranch = pipeline.readFromQueue(layerStatsQueue) - .sinkTo("stats", 1, tileStatsWriter(layerStatsPath)); + // instance method: needs config to resolve layerstats format + .sinkTo("stats", 1, writer.tileStatsWriter(layerStatsPath)); } var loggers = ProgressLoggers.create() @@ -210,14 +211,17 @@ public static void writeOutput(FeatureGroup features, WriteableTileArchive outpu timer.stop(); } - private static WorkerPipeline.SinkStep tileStatsWriter(Path layerStatsPath) { + private WorkerPipeline.SinkStep tileStatsWriter(Path layerStatsPath) { return prev -> { - try (var statsWriter = TileSizeStats.newWriter(layerStatsPath)) { - statsWriter.write(TileSizeStats.headerRow()); + try (var statsWriter = TileSizeStats.createWriter(config.layerstatsFormat(), layerStatsPath)) { for (var batch : prev) { for (var encodedTile : batch.out().get()) { - for (var line : encodedTile.layerStats()) { - statsWriter.write(line); + if (encodedTile.layerStatsData() != null) { + statsWriter.write( + encodedTile.layerStatsData().coord(), + encodedTile.layerStatsData().archivedBytes(), + encodedTile.layerStatsData().layerStats() + ); } } } @@ -280,7 +284,6 @@ private void tileEncoderSink(Iterable prev) throws IOException { boolean lastIsFill = false; List lastLayerStats = null; boolean skipFilled = config.skipFilledTiles(); - var layerStatsSerializer = TileSizeStats.newThreadLocalSerializer(); boolean includeIds = !config.excludeIds(); var tileStatsUpdater = tileStats.threadLocalUpdater(); @@ -373,16 +376,17 @@ private void tileEncoderSink(Iterable prev) throws IOException { } if (!(skipFilled && lastIsFill) && bytes != null) { tileStatsUpdater.recordTile(tileFeatures.tileCoord(), bytes.length, layerStats); - List layerStatsRows = config.outputLayerStats() ? - layerStatsSerializer.formatOutputRows(tileFeatures.tileCoord(), bytes.length, layerStats) : - List.of(); + // store raw stats; writer decides format (tsv or parquet) + LayerStatsData layerStatsData = config.outputLayerStats() ? + new LayerStatsData(tileFeatures.tileCoord(), bytes.length, layerStats) : + null; result.add( new TileEncodingResult( tileFeatures.tileCoord(), bytes, encoded.length, tileDataHash == null ? OptionalLong.empty() : OptionalLong.of(tileDataHash), - layerStatsRows + layerStatsData ) ); } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileEncodingResult.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileEncodingResult.java index ecbd8fb3e2..d3d650bce0 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileEncodingResult.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileEncodingResult.java @@ -1,25 +1,33 @@ package com.onthegomap.planetiler.archive; import com.onthegomap.planetiler.geo.TileCoord; +import com.onthegomap.planetiler.util.TileSizeStats; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.OptionalLong; +/** Layer statistics data for a tile. */ +record LayerStatsData( + TileCoord coord, + int archivedBytes, + List layerStats +) {} + public record TileEncodingResult( TileCoord coord, byte[] tileData, int rawTileSize, /* will always be empty in non-compact mode and might also be empty in compact mode */ OptionalLong tileDataHash, - List layerStats + LayerStatsData layerStatsData ) { public TileEncodingResult( TileCoord coord, byte[] tileData, OptionalLong tileDataHash ) { - this(coord, tileData, tileData.length, tileDataHash, List.of()); + this(coord, tileData, tileData.length, tileDataHash, null); } @Override diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java index 3a826153c9..38a3037174 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java @@ -65,6 +65,7 @@ public record PlanetilerConfig( boolean mltReorderFeature, boolean mltSharedDictionaries, boolean outputLayerStats, + String layerstatsFormat, String debugUrlPattern, Path tmpDir, Path tileWeights, @@ -237,6 +238,8 @@ public static PlanetilerConfig from(Arguments arguments) { "Allow re-ordering output features within each layer when tile format=MLT to reduce tile sizes", false), arguments.getBoolean("mlt_shared_dict", "Share dictionaries between string fields when tile format=MLT", false), arguments.getBoolean("output_layerstats", "output a tsv.gz file for each tile/layer size", false), + arguments.getString("layerstats_format|layerstats-format", + "layerstats output format (tsv or parquet)", "tsv"), arguments.getString("debug_url", "debug url to use for displaying tiles with {z} {lat} {lon} placeholders", "https://onthegomap.github.io/planetiler-demo/#{z}/{lat}/{lon}"), tmpDir, diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java index 2f61522070..90366c3312 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java @@ -39,6 +39,14 @@ import java.util.concurrent.atomic.AtomicLong; import me.lemire.integercompression.IntWrapper; import org.apache.commons.lang3.tuple.Pair; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.SimpleGroupFactory; +import org.apache.parquet.hadoop.ParquetWriter; +import org.apache.parquet.hadoop.example.ExampleParquetWriter; +import org.apache.parquet.hadoop.metadata.CompressionCodecName; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Types; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryCollection; import org.maplibre.mlt.converter.encodings.MltTypeMap; @@ -82,6 +90,21 @@ public static Path getDefaultLayerstatsPath(Path archive) { return archive.resolveSibling(archive.getFileName() + ".layerstats.tsv.gz"); } + /** Returns the default path for layerstats based on the archive and output format. */ + public static Path getDefaultLayerstatsPath(Path archive, String format) { + String extension = "parquet".equalsIgnoreCase(format) ? ".layerstats.parquet" : ".layerstats.tsv.gz"; + return archive.resolveSibling(archive.getFileName() + extension); + } + + /** Creates a layerstats writer based on the output format. */ + public static LayerStatsWriter createWriter(String format, Path output) throws IOException { + if ("parquet".equalsIgnoreCase(format)) { + return new ParquetLayerStatsWriter(output); + } else { + return new TsvLayerStatsWriter(output); + } + } + public static void main(String... args) { var arguments = Arguments.fromArgsOrConfigFile(args); var config = PlanetilerConfig.from(arguments); @@ -94,12 +117,14 @@ public static void main(String... args) { var inputString = arguments.getString("input", "input file"); var input = TileArchiveConfig.from(inputString); var localPath = input.getLocalPath(); + String format = config.layerstatsFormat(); var output = localPath == null ? arguments.file("output", "output file") : - arguments.file("output", "output file", getDefaultLayerstatsPath(localPath)); + arguments.file("output", "output file", getDefaultLayerstatsPath(localPath, format)); var counter = new AtomicLong(0); var timer = stats.startStage("tilestats"); - record Batch(List tiles, CompletableFuture> stats) {} + record BatchData(TileCoord coord, int archivedBytes, List layerStats) {} + record Batch(List tiles, CompletableFuture> stats) {} WorkQueue writerQueue = new WorkQueue<>("tilestats_write_queue", 1_000, 1, stats); var pipeline = WorkerPipeline.start("tilestats", stats); var readBranch = pipeline @@ -137,9 +162,9 @@ record Batch(List tiles, CompletableFuture> stats) {} List layerStats = null; var updater = tileStats.threadLocalUpdater(); - var layerStatsSerializer = TileSizeStats.newThreadLocalSerializer(); for (var batch : prev) { - List lines = new ArrayList<>(batch.tiles.size()); + // collect raw stats per tile; serialization happens in write stage + List batchData = new ArrayList<>(batch.tiles.size()); for (var tile : batch.tiles) { if (!Arrays.equals(zipped, tile.bytes())) { zipped = tile.bytes(); @@ -148,19 +173,18 @@ record Batch(List tiles, CompletableFuture> stats) {} layerStats = computeTileStats(decoded); } updater.recordTile(tile.coord(), zipped.length, layerStats); - lines.addAll(layerStatsSerializer.formatOutputRows(tile.coord(), zipped.length, layerStats)); + batchData.add(new BatchData(tile.coord(), zipped.length, layerStats)); } - batch.stats.complete(lines); + batch.stats.complete(batchData); } }); var writeBranch = pipeline.readFromQueue(writerQueue) .sinkTo("write", 1, prev -> { - try (var writer = newWriter(output)) { - writer.write(headerRow()); + try (var writer = createWriter(format, output)) { for (var batch : prev) { - for (var line : batch.stats.get()) { - writer.write(line); + for (var data : batch.stats.get()) { + writer.write(data.coord, data.archivedBytes, data.layerStats); } } } @@ -419,4 +443,103 @@ public int compareTo(LayerStats o) { return layer.compareTo(o.layer); } } + + /** + * Writer abstraction for layerstats output. + */ + public interface LayerStatsWriter extends AutoCloseable { + + /** Write layerstats for a tile. */ + void write(TileCoord tileCoord, int archivedBytes, List layerStats) throws IOException; + + @Override + void close() throws IOException; + } + + /** + * TSV writer for layerstats. + */ + private static class TsvLayerStatsWriter implements LayerStatsWriter { + + private final Writer writer; + private final TsvSerializer serializer; + + TsvLayerStatsWriter(Path output) throws IOException { + this.writer = newWriter(output); + this.serializer = newThreadLocalSerializer(); + writer.write(headerRow()); + } + + @Override + public void write(TileCoord tileCoord, int archivedBytes, List layerStats) throws IOException { + for (var line : serializer.formatOutputRows(tileCoord, archivedBytes, layerStats)) { + writer.write(line); + } + } + + @Override + public void close() throws IOException { + writer.close(); + } + } + + /** + * Parquet writer for layerstats. + */ + private static class ParquetLayerStatsWriter implements LayerStatsWriter { + + private static final MessageType SCHEMA = Types.buildMessage() + .required(PrimitiveType.PrimitiveTypeName.INT32).named("z") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("x") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("y") + .required(PrimitiveType.PrimitiveTypeName.INT64).named("hilbert") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("archived_tile_bytes") + .required(PrimitiveType.PrimitiveTypeName.BINARY).as(org.apache.parquet.schema.LogicalTypeAnnotation.stringType()) + .named("layer") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("layer_bytes") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("layer_features") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("layer_geometries") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("layer_attr_bytes") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("layer_attr_keys") + .required(PrimitiveType.PrimitiveTypeName.INT32).named("layer_attr_values") + .named("layerstats"); + + private final ParquetWriter writer; + private final SimpleGroupFactory groupFactory; + + ParquetLayerStatsWriter(Path output) throws IOException { + this.groupFactory = new SimpleGroupFactory(SCHEMA); + org.apache.hadoop.fs.Path hadoopPath = new org.apache.hadoop.fs.Path(output.toString()); + this.writer = ExampleParquetWriter.builder(hadoopPath) + .withType(SCHEMA) + .withCompressionCodec(CompressionCodecName.SNAPPY) + .build(); + } + + @Override + public void write(TileCoord tileCoord, int archivedBytes, List layerStats) throws IOException { + long hilbert = tileCoord.hilbertEncoded(); + for (var layer : layerStats) { + Group group = groupFactory.newGroup() + .append("z", tileCoord.z()) + .append("x", tileCoord.x()) + .append("y", tileCoord.y()) + .append("hilbert", hilbert) + .append("archived_tile_bytes", archivedBytes) + .append("layer", layer.layer) + .append("layer_bytes", layer.layerBytes) + .append("layer_features", layer.layerFeatures) + .append("layer_geometries", layer.layerGeometries) + .append("layer_attr_bytes", layer.layerAttrBytes) + .append("layer_attr_keys", layer.layerAttrKeys) + .append("layer_attr_values", layer.layerAttrValues); + writer.write(group); + } + } + + @Override + public void close() throws IOException { + writer.close(); + } + } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java index 5ab6ef360b..e5b111ccbe 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java @@ -2,15 +2,19 @@ import static com.onthegomap.planetiler.TestUtils.newPoint; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.onthegomap.planetiler.VectorTile; import com.onthegomap.planetiler.geo.TileCoord; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.regex.Pattern; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.maplibre.mlt.converter.ConversionConfig; import org.maplibre.mlt.converter.FeatureTableOptimizations; import org.maplibre.mlt.converter.MltConverter; @@ -221,4 +225,39 @@ void computeStats2FeaturesNested() throws IOException { .trim(), (TileSizeStats.headerRow() + String.join("", formatted)).trim()); } + + @Test + void testParquetOutput(@TempDir Path tempDir) throws IOException { + Path parquetFile = tempDir.resolve("layerstats.parquet"); + var stats = List.of( + new TileSizeStats.LayerStats("layer1", 100, 5, 5, 20, 2, 3), + new TileSizeStats.LayerStats("layer2", 150, 10, 10, 30, 3, 4) + ); + + // Write Parquet file + try (var writer = TileSizeStats.createWriter("parquet", parquetFile)) { + writer.write(TileCoord.ofXYZ(1, 2, 3), 999, stats); + } + + // Verify file exists and has content + assertTrue(Files.exists(parquetFile)); + assertTrue(Files.size(parquetFile) > 0); + } + + @Test + void testTsvOutput(@TempDir Path tempDir) throws IOException { + Path tsvFile = tempDir.resolve("layerstats.tsv.gz"); + var stats = List.of( + new TileSizeStats.LayerStats("layer1", 100, 5, 5, 20, 2, 3) + ); + + // Write TSV file + try (var writer = TileSizeStats.createWriter("tsv", tsvFile)) { + writer.write(TileCoord.ofXYZ(1, 2, 3), 999, stats); + } + + // Verify file exists and has content + assertTrue(Files.exists(tsvFile)); + assertTrue(Files.size(tsvFile) > 0); + } } From 8ed5a67aba58b0e6bb8350b85f381d941eaa1d1a Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Wed, 15 Apr 2026 16:36:28 +0530 Subject: [PATCH 2/7] Make Parquet default, remove TSV support --- .../planetiler/config/PlanetilerConfig.java | 4 +- .../planetiler/util/TileSizeStats.java | 42 +++---------------- .../planetiler/util/TileSizeStatsTest.java | 35 +++++++++------- 3 files changed, 28 insertions(+), 53 deletions(-) diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java index 38a3037174..70545bf5db 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java @@ -237,9 +237,9 @@ public static PlanetilerConfig from(Arguments arguments) { arguments.getBoolean("mlt_reorder_features", "Allow re-ordering output features within each layer when tile format=MLT to reduce tile sizes", false), arguments.getBoolean("mlt_shared_dict", "Share dictionaries between string fields when tile format=MLT", false), - arguments.getBoolean("output_layerstats", "output a tsv.gz file for each tile/layer size", false), + arguments.getBoolean("output_layerstats", "output a parquet file for each tile/layer size", false), arguments.getString("layerstats_format|layerstats-format", - "layerstats output format (tsv or parquet)", "tsv"), + "layerstats output format (parquet)", "parquet"), arguments.getString("debug_url", "debug url to use for displaying tiles with {z} {lat} {lon} placeholders", "https://onthegomap.github.io/planetiler-demo/#{z}/{lat}/{lon}"), tmpDir, diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java index 90366c3312..7d4c805dca 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java @@ -72,7 +72,7 @@ * *
  * {@code
- * java -jar planetiler.jar stats --input= --output=layerstats.tsv.gz
+ * java -jar planetiler.jar stats --input= --output=layerstats.parquet
  * }
  * 
*/ @@ -87,22 +87,17 @@ public class TileSizeStats { /** Returns the default path that a layerstats file should go relative to an existing archive. */ public static Path getDefaultLayerstatsPath(Path archive) { - return archive.resolveSibling(archive.getFileName() + ".layerstats.tsv.gz"); + return archive.resolveSibling(archive.getFileName() + ".layerstats.parquet"); } /** Returns the default path for layerstats based on the archive and output format. */ public static Path getDefaultLayerstatsPath(Path archive, String format) { - String extension = "parquet".equalsIgnoreCase(format) ? ".layerstats.parquet" : ".layerstats.tsv.gz"; - return archive.resolveSibling(archive.getFileName() + extension); + return archive.resolveSibling(archive.getFileName() + ".layerstats.parquet"); } - /** Creates a layerstats writer based on the output format. */ + /** Creates a Parquet layerstats writer. */ public static LayerStatsWriter createWriter(String format, Path output) throws IOException { - if ("parquet".equalsIgnoreCase(format)) { - return new ParquetLayerStatsWriter(output); - } else { - return new TsvLayerStatsWriter(output); - } + return new ParquetLayerStatsWriter(output); } public static void main(String... args) { @@ -456,33 +451,6 @@ public interface LayerStatsWriter extends AutoCloseable { void close() throws IOException; } - /** - * TSV writer for layerstats. - */ - private static class TsvLayerStatsWriter implements LayerStatsWriter { - - private final Writer writer; - private final TsvSerializer serializer; - - TsvLayerStatsWriter(Path output) throws IOException { - this.writer = newWriter(output); - this.serializer = newThreadLocalSerializer(); - writer.write(headerRow()); - } - - @Override - public void write(TileCoord tileCoord, int archivedBytes, List layerStats) throws IOException { - for (var line : serializer.formatOutputRows(tileCoord, archivedBytes, layerStats)) { - writer.write(line); - } - } - - @Override - public void close() throws IOException { - writer.close(); - } - } - /** * Parquet writer for layerstats. */ diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java index e5b111ccbe..fba0ec37e8 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java @@ -242,22 +242,29 @@ void testParquetOutput(@TempDir Path tempDir) throws IOException { // Verify file exists and has content assertTrue(Files.exists(parquetFile)); assertTrue(Files.size(parquetFile) > 0); - } - @Test - void testTsvOutput(@TempDir Path tempDir) throws IOException { - Path tsvFile = tempDir.resolve("layerstats.tsv.gz"); - var stats = List.of( - new TileSizeStats.LayerStats("layer1", 100, 5, 5, 20, 2, 3) - ); + // read back and verify data + var inputFile = blue.strategic.parquet.ParquetReader.makeInputFile(parquetFile.toFile()); + try (var fileReader = org.apache.parquet.hadoop.ParquetFileReader.open(inputFile)) { + var schema = fileReader.getFooter().getFileMetaData().getSchema(); + var recordCount = fileReader.getRecordCount(); - // Write TSV file - try (var writer = TileSizeStats.createWriter("tsv", tsvFile)) { - writer.write(TileCoord.ofXYZ(1, 2, 3), 999, stats); + // verify schema has expected columns + assertEquals(12, schema.getFieldCount()); + assertTrue(schema.containsField("z")); + assertTrue(schema.containsField("x")); + assertTrue(schema.containsField("y")); + assertTrue(schema.containsField("hilbert")); + assertTrue(schema.containsField("archived_tile_bytes")); + assertTrue(schema.containsField("layer")); + assertTrue(schema.containsField("layer_bytes")); + assertTrue(schema.containsField("layer_features")); + assertTrue(schema.containsField("layer_geometries")); + assertTrue(schema.containsField("layer_attr_bytes")); + assertTrue(schema.containsField("layer_attr_keys")); + assertTrue(schema.containsField("layer_attr_values")); + assertEquals(2, recordCount); } - - // Verify file exists and has content - assertTrue(Files.exists(tsvFile)); - assertTrue(Files.size(tsvFile) > 0); } + } From 168d795d1eda8310b73857dd0d1c870ae45886fd Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Wed, 15 Apr 2026 17:01:36 +0530 Subject: [PATCH 3/7] Fix test and update all TSV references --- layerstats/README.md | 8 +-- .../com/onthegomap/planetiler/Planetiler.java | 2 +- .../planetiler/util/TileSizeStats.java | 6 +- .../planetiler/PlanetilerTests.java | 71 ++++++++----------- 4 files changed, 37 insertions(+), 50 deletions(-) diff --git a/layerstats/README.md b/layerstats/README.md index 8baf345d10..9fcad3d8db 100644 --- a/layerstats/README.md +++ b/layerstats/README.md @@ -5,14 +5,14 @@ This page describes how to generate and analyze layer stats data to find ways to ### Generating Layer Stats -Run planetiler with `--output-layerstats` to generate an extra `.layerstats.tsv.gz` file with a row for each +Run planetiler with `--output-layerstats` to generate an extra `.layerstats.parquet` file with a row for each layer in each tile that can be used to analyze tile sizes. You can also get stats for an existing archive by running: ```bash -java -jar planetiler.jar stats --input= --output=layerstats.tsv.gz +java -jar planetiler.jar stats --input= --output=layerstats.parquet ``` -The output is a gzipped tsv with a row per layer on each tile and the following columns: +The output is a Parquet file with a row per layer on each tile and the following columns: | column | description | |---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------| @@ -34,7 +34,7 @@ The output is a gzipped tsv with a row per layer on each tile and the following Load a layer stats file in [duckdb](https://duckdb.org/): ```sql -CREATE TABLE layerstats AS SELECT * FROM 'output.pmtiles.layerstats.tsv.gz'; +CREATE TABLE layerstats AS SELECT * FROM 'output.pmtiles.layerstats.parquet'; ``` Then get the biggest layers: diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java index 44ee8ef763..cdb5f9bdde 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java @@ -835,7 +835,7 @@ public void run() { } Path layerStatsPath = arguments.file("layer_stats", "layer stats output path", - // default to .layerstats.tsv.gz or .layerstats.parquet based on format + // default to .layerstats.parquet TileSizeStats.getDefaultLayerstatsPath(Optional.ofNullable(output.getLocalPath()).orElse(Path.of("output")), config.layerstatsFormat())); diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java index 7d4c805dca..ae0fc95739 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java @@ -65,10 +65,10 @@ * Utilities for extracting tile and layer size summaries from encoded vector tiles. *

* {@link #computeTileStats(VectorTileProto.Tile)} extracts statistics about each layer in a tile and - * {@link TsvSerializer} formats them as row of a TSV file to write. + * writes them to a Parquet file. *

- * To generate a tsv.gz file with stats for each tile, you can add {@code --output-layerstats} option when generating an - * archive, or run the following an existing archive: + * To generate a Parquet file with stats for each tile, you can add {@code --output-layerstats} option when generating an + * archive, or run the following on an existing archive: * *

  * {@code
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
index 1f402eb4e3..760e70dd74 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
@@ -2827,54 +2827,41 @@ public void processFeature(SourceFeature source, FeatureCollector features) {
       }
     }
 
-    final Path layerstats = outputPath.resolveSibling(outputPath.getFileName().toString() + ".layerstats.tsv.gz");
+    final Path layerstats = outputPath.resolveSibling(outputPath.getFileName().toString() + ".layerstats.parquet");
     if (args.contains("--output-layerstats")) {
       assertTrue(Files.exists(layerstats));
-      byte[] data = Files.readAllBytes(layerstats);
-      byte[] uncompressed = Gzip.gunzip(data);
-      String[] lines = new String(uncompressed, StandardCharsets.UTF_8).split("\n");
-      assertEquals(33, lines.length);
-
-      assertEquals(List.of(
-        "z",
-        "x",
-        "y",
-        "hilbert",
-        "archived_tile_bytes",
-        "layer",
-        "layer_bytes",
-        "layer_features",
-        "layer_geometries",
-        "layer_attr_bytes",
-        "layer_attr_keys",
-        "layer_attr_values"
-      ), List.of(lines[0].split("\t")), lines[0]);
-
-      var mapper = new CsvMapper();
-      var reader = mapper
-        .readerFor(Map.class)
-        .with(CsvSchema.emptySchema().withColumnSeparator('\t').withLineSeparator("\n").withHeader());
-      try (var items = reader.readValues(uncompressed)) {
-        while (items.hasNext()) {
-          @SuppressWarnings("unchecked") Map next = (Map) items.next();
-          int z = Integer.parseInt(next.get("z"));
-          int x = Integer.parseInt(next.get("x"));
-          int y = Integer.parseInt(next.get("y"));
-          long hilbert = Long.parseLong(next.get("hilbert"));
-          assertEquals(hilbert, TileCoord.ofXYZ(x, y, z).hilbertEncoded());
-          assertTrue(Integer.parseInt(next.get("z")) <= 14, "bad z: " + next);
-        }
+
+      // Read and verify Parquet file
+      var inputFile = blue.strategic.parquet.ParquetReader.makeInputFile(layerstats.toFile());
+      try (var fileReader = org.apache.parquet.hadoop.ParquetFileReader.open(inputFile)) {
+        var schema = fileReader.getFooter().getFileMetaData().getSchema();
+        var recordCount = fileReader.getRecordCount();
+
+        // Verify schema has expected columns
+        assertEquals(12, schema.getFieldCount());
+        assertTrue(schema.containsField("z"));
+        assertTrue(schema.containsField("x"));
+        assertTrue(schema.containsField("y"));
+        assertTrue(schema.containsField("hilbert"));
+        assertTrue(schema.containsField("archived_tile_bytes"));
+        assertTrue(schema.containsField("layer"));
+        assertTrue(schema.containsField("layer_bytes"));
+        assertTrue(schema.containsField("layer_features"));
+        assertTrue(schema.containsField("layer_geometries"));
+        assertTrue(schema.containsField("layer_attr_bytes"));
+        assertTrue(schema.containsField("layer_attr_keys"));
+        assertTrue(schema.containsField("layer_attr_values"));
+
+        // Verify we have records (32 layers from the test)
+        assertEquals(32, recordCount);
       }
 
+
       // ensure tilestats standalone executable produces same output
-      var standaloneLayerstatsOutput = tempDir.resolve("layerstats2.tsv.gz");
+      var standaloneLayerstatsOutput = tempDir.resolve("layerstats2.parquet");
       TileSizeStats.main("--input=" + outputPath, "--output=" + standaloneLayerstatsOutput);
-      byte[] standaloneData = Files.readAllBytes(standaloneLayerstatsOutput);
-      byte[] standaloneUncompressed = Gzip.gunzip(standaloneData);
-      assertEquals(
-        new String(uncompressed, StandardCharsets.UTF_8),
-        new String(standaloneUncompressed, StandardCharsets.UTF_8)
-      );
+      assertTrue(Files.exists(standaloneLayerstatsOutput));
+      assertTrue(Files.size(standaloneLayerstatsOutput) > 0);
     } else {
       assertFalse(Files.exists(layerstats));
     }

From d0e8922efc8461b871d37f5f1aa1a7148b7a7ed6 Mon Sep 17 00:00:00 2001
From: Ninad Kale 
Date: Fri, 17 Apr 2026 15:09:03 +0530
Subject: [PATCH 4/7] Update
 planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java

Co-authored-by: Michael Barry 
---
 .../java/com/onthegomap/planetiler/config/PlanetilerConfig.java | 2 --
 1 file changed, 2 deletions(-)

diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java
index 70545bf5db..7b67312ddf 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java
@@ -238,8 +238,6 @@ public static PlanetilerConfig from(Arguments arguments) {
         "Allow re-ordering output features within each layer when tile format=MLT to reduce tile sizes", false),
       arguments.getBoolean("mlt_shared_dict", "Share dictionaries between string fields when tile format=MLT", false),
       arguments.getBoolean("output_layerstats", "output a parquet file for each tile/layer size", false),
-      arguments.getString("layerstats_format|layerstats-format",
-        "layerstats output format (parquet)", "parquet"),
       arguments.getString("debug_url", "debug url to use for displaying tiles with {z} {lat} {lon} placeholders",
         "https://onthegomap.github.io/planetiler-demo/#{z}/{lat}/{lon}"),
       tmpDir,

From 051d2755601c34df09331ea7311dd3666395e3c8 Mon Sep 17 00:00:00 2001
From: Ninad Kale 
Date: Fri, 17 Apr 2026 18:58:54 +0530
Subject: [PATCH 5/7] Remove unused format arg, verify parquet row values

---
 .../src/main/java/com/onthegomap/planetiler/Planetiler.java    | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java
index cdb5f9bdde..80eb174d8b 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java
@@ -836,8 +836,7 @@ public void run() {
 
     Path layerStatsPath = arguments.file("layer_stats", "layer stats output path",
       // default to .layerstats.parquet
-      TileSizeStats.getDefaultLayerstatsPath(Optional.ofNullable(output.getLocalPath()).orElse(Path.of("output")),
-        config.layerstatsFormat()));
+      TileSizeStats.getDefaultLayerstatsPath(Optional.ofNullable(output.getLocalPath()).orElse(Path.of("output"))));
 
     if (config.tileWriteThreads() < 1) {
       throw new IllegalArgumentException("require tile_write_threads >= 1");

From f86b74f7a205c49b4c010c75df0477240a0523e1 Mon Sep 17 00:00:00 2001
From: Ninad Kale 
Date: Fri, 17 Apr 2026 19:07:44 +0530
Subject: [PATCH 6/7] Fix compile: remove layerstatsFormat field, clean
 warnings

---
 .../planetiler/archive/TileArchiveWriter.java |  6 +-
 .../planetiler/config/PlanetilerConfig.java   |  1 -
 .../planetiler/util/TileSizeStats.java        | 12 +---
 .../planetiler/PlanetilerTests.java           | 72 +++++++++++--------
 .../planetiler/util/TileSizeStatsTest.java    | 72 ++++++++++++-------
 5 files changed, 93 insertions(+), 70 deletions(-)

diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java
index bc80850c6a..3cb046b494 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java
@@ -213,7 +213,7 @@ public static void writeOutput(FeatureGroup features, WriteableTileArchive outpu
 
   private WorkerPipeline.SinkStep tileStatsWriter(Path layerStatsPath) {
     return prev -> {
-      try (var statsWriter = TileSizeStats.createWriter(config.layerstatsFormat(), layerStatsPath)) {
+      try (var statsWriter = TileSizeStats.createWriter(layerStatsPath)) {
         for (var batch : prev) {
           for (var encodedTile : batch.out().get()) {
             if (encodedTile.layerStatsData() != null) {
@@ -532,9 +532,5 @@ private record TileBatch(
     public int size() {
       return in.size();
     }
-
-    public boolean isEmpty() {
-      return in.isEmpty();
-    }
   }
 }
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java
index 7b67312ddf..a0fc6e80e4 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/config/PlanetilerConfig.java
@@ -65,7 +65,6 @@ public record PlanetilerConfig(
   boolean mltReorderFeature,
   boolean mltSharedDictionaries,
   boolean outputLayerStats,
-  String layerstatsFormat,
   String debugUrlPattern,
   Path tmpDir,
   Path tileWeights,
diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java
index ae0fc95739..dfa352e754 100644
--- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java
+++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/TileSizeStats.java
@@ -90,13 +90,8 @@ public static Path getDefaultLayerstatsPath(Path archive) {
     return archive.resolveSibling(archive.getFileName() + ".layerstats.parquet");
   }
 
-  /** Returns the default path for layerstats based on the archive and output format. */
-  public static Path getDefaultLayerstatsPath(Path archive, String format) {
-    return archive.resolveSibling(archive.getFileName() + ".layerstats.parquet");
-  }
-
   /** Creates a Parquet layerstats writer. */
-  public static LayerStatsWriter createWriter(String format, Path output) throws IOException {
+  public static LayerStatsWriter createWriter(Path output) throws IOException {
     return new ParquetLayerStatsWriter(output);
   }
 
@@ -112,10 +107,9 @@ public static void main(String... args) {
     var inputString = arguments.getString("input", "input file");
     var input = TileArchiveConfig.from(inputString);
     var localPath = input.getLocalPath();
-    String format = config.layerstatsFormat();
     var output = localPath == null ?
       arguments.file("output", "output file") :
-      arguments.file("output", "output file", getDefaultLayerstatsPath(localPath, format));
+      arguments.file("output", "output file", getDefaultLayerstatsPath(localPath));
     var counter = new AtomicLong(0);
     var timer = stats.startStage("tilestats");
     record BatchData(TileCoord coord, int archivedBytes, List layerStats) {}
@@ -176,7 +170,7 @@ record Batch(List tiles, CompletableFuture> stats) {}
 
     var writeBranch = pipeline.readFromQueue(writerQueue)
       .sinkTo("write", 1, prev -> {
-        try (var writer = createWriter(format, output)) {
+        try (var writer = createWriter(output)) {
           for (var batch : prev) {
             for (var data : batch.stats.get()) {
               writer.write(data.coord, data.archivedBytes, data.layerStats);
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
index 760e70dd74..d6e4d84331 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
@@ -3,8 +3,6 @@
 import static com.onthegomap.planetiler.TestUtils.*;
 import static org.junit.jupiter.api.Assertions.*;
 
-import com.fasterxml.jackson.dataformat.csv.CsvMapper;
-import com.fasterxml.jackson.dataformat.csv.CsvSchema;
 import com.onthegomap.planetiler.archive.ReadableTileArchive;
 import com.onthegomap.planetiler.archive.TileArchiveConfig;
 import com.onthegomap.planetiler.archive.TileArchiveMetadata;
@@ -37,10 +35,8 @@
 import com.onthegomap.planetiler.util.BuildInfo;
 import com.onthegomap.planetiler.util.FileUtils;
 import com.onthegomap.planetiler.util.FunctionThatThrows;
-import com.onthegomap.planetiler.util.Gzip;
 import com.onthegomap.planetiler.util.TileSizeStats;
 import java.io.IOException;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -2767,7 +2763,7 @@ public void processFeature(SourceFeature source, FeatureCollector features) {
         }
       })
       .addOsmSource("osm", tempOsm)
-      .addNaturalEarthSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite"))
+      .addGeoPackageSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite"), null)
       .addShapefileSource("shapefile", TestUtils.pathToResource("shapefile.zip"))
       .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg.zip"), null)
       .addGeoJsonSource("geojson", TestUtils.pathToResource("featurecollection.geojson"), null)
@@ -2831,37 +2827,51 @@ public void processFeature(SourceFeature source, FeatureCollector features) {
     if (args.contains("--output-layerstats")) {
       assertTrue(Files.exists(layerstats));
 
-      // Read and verify Parquet file
-      var inputFile = blue.strategic.parquet.ParquetReader.makeInputFile(layerstats.toFile());
-      try (var fileReader = org.apache.parquet.hadoop.ParquetFileReader.open(inputFile)) {
-        var schema = fileReader.getFooter().getFileMetaData().getSchema();
-        var recordCount = fileReader.getRecordCount();
-
-        // Verify schema has expected columns
-        assertEquals(12, schema.getFieldCount());
-        assertTrue(schema.containsField("z"));
-        assertTrue(schema.containsField("x"));
-        assertTrue(schema.containsField("y"));
-        assertTrue(schema.containsField("hilbert"));
-        assertTrue(schema.containsField("archived_tile_bytes"));
-        assertTrue(schema.containsField("layer"));
-        assertTrue(schema.containsField("layer_bytes"));
-        assertTrue(schema.containsField("layer_features"));
-        assertTrue(schema.containsField("layer_geometries"));
-        assertTrue(schema.containsField("layer_attr_bytes"));
-        assertTrue(schema.containsField("layer_attr_keys"));
-        assertTrue(schema.containsField("layer_attr_values"));
-
-        // Verify we have records (32 layers from the test)
-        assertEquals(32, recordCount);
+      blue.strategic.parquet.Hydrator, Map> hydrator =
+        new blue.strategic.parquet.Hydrator<>() {
+          @Override
+          public Map start() {
+            return new HashMap<>();
+          }
+
+          @Override
+          public Map add(Map target, String heading, Object value) {
+            target.put(heading, value);
+            return target;
+          }
+
+          @Override
+          public Map finish(Map target) {
+            return target;
+          }
+        };
+
+      List> rows;
+      try (var stream = blue.strategic.parquet.ParquetReader.streamContent(
+        layerstats.toFile(), blue.strategic.parquet.HydratorSupplier.constantly(hydrator))) {
+        rows = stream.toList();
       }
 
+      assertEquals(32, rows.size());
+      for (var row : rows) {
+        int z = ((Number) row.get("z")).intValue();
+        int x = ((Number) row.get("x")).intValue();
+        int y = ((Number) row.get("y")).intValue();
+        long hilbert = ((Number) row.get("hilbert")).longValue();
+        assertEquals(hilbert, TileCoord.ofXYZ(x, y, z).hilbertEncoded(), "bad hilbert: " + row);
+        assertTrue(z <= 14, "bad z: " + row);
+      }
 
       // ensure tilestats standalone executable produces same output
       var standaloneLayerstatsOutput = tempDir.resolve("layerstats2.parquet");
       TileSizeStats.main("--input=" + outputPath, "--output=" + standaloneLayerstatsOutput);
-      assertTrue(Files.exists(standaloneLayerstatsOutput));
-      assertTrue(Files.size(standaloneLayerstatsOutput) > 0);
+      List> standaloneRows;
+      try (var stream = blue.strategic.parquet.ParquetReader.streamContent(
+        standaloneLayerstatsOutput.toFile(), blue.strategic.parquet.HydratorSupplier.constantly(hydrator))) {
+        standaloneRows = stream.toList();
+      }
+      assertEquals(rows.size(), standaloneRows.size());
+      assertEquals(rows, standaloneRows);
     } else {
       assertFalse(Files.exists(layerstats));
     }
@@ -3043,7 +3053,7 @@ private void runWithProfile(Path tempDir, Profile profile, boolean force) {
     Planetiler.create(Arguments.of("tmpdir", tempDir, "force", Boolean.toString(force)))
       .setProfile(profile)
       .addOsmSource("osm", TestUtils.pathToResource("monaco-latest.osm.pbf"))
-      .addNaturalEarthSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite"))
+      .addGeoPackageSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite"), null)
       .addShapefileSource("shapefile", TestUtils.pathToResource("shapefile.zip"))
       .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg.zip"), null)
       .setOutput(tempDir.resolve("output.mbtiles"))
diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java
index fba0ec37e8..51e9699b24 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/TileSizeStatsTest.java
@@ -9,6 +9,7 @@
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -229,42 +230,65 @@ void computeStats2FeaturesNested() throws IOException {
   @Test
   void testParquetOutput(@TempDir Path tempDir) throws IOException {
     Path parquetFile = tempDir.resolve("layerstats.parquet");
+    var tileCoord = TileCoord.ofXYZ(1, 2, 3);
     var stats = List.of(
       new TileSizeStats.LayerStats("layer1", 100, 5, 5, 20, 2, 3),
       new TileSizeStats.LayerStats("layer2", 150, 10, 10, 30, 3, 4)
     );
 
-    // Write Parquet file
-    try (var writer = TileSizeStats.createWriter("parquet", parquetFile)) {
-      writer.write(TileCoord.ofXYZ(1, 2, 3), 999, stats);
+    try (var writer = TileSizeStats.createWriter(parquetFile)) {
+      writer.write(tileCoord, 999, stats);
     }
 
-    // Verify file exists and has content
     assertTrue(Files.exists(parquetFile));
     assertTrue(Files.size(parquetFile) > 0);
 
-    // read back and verify data
-    var inputFile = blue.strategic.parquet.ParquetReader.makeInputFile(parquetFile.toFile());
-    try (var fileReader = org.apache.parquet.hadoop.ParquetFileReader.open(inputFile)) {
-      var schema = fileReader.getFooter().getFileMetaData().getSchema();
-      var recordCount = fileReader.getRecordCount();
+    blue.strategic.parquet.Hydrator, Map> hydrator =
+      new blue.strategic.parquet.Hydrator<>() {
+        @Override
+        public Map start() {
+          return new HashMap<>();
+        }
 
-      // verify schema has expected columns
-      assertEquals(12, schema.getFieldCount());
-      assertTrue(schema.containsField("z"));
-      assertTrue(schema.containsField("x"));
-      assertTrue(schema.containsField("y"));
-      assertTrue(schema.containsField("hilbert"));
-      assertTrue(schema.containsField("archived_tile_bytes"));
-      assertTrue(schema.containsField("layer"));
-      assertTrue(schema.containsField("layer_bytes"));
-      assertTrue(schema.containsField("layer_features"));
-      assertTrue(schema.containsField("layer_geometries"));
-      assertTrue(schema.containsField("layer_attr_bytes"));
-      assertTrue(schema.containsField("layer_attr_keys"));
-      assertTrue(schema.containsField("layer_attr_values"));
-      assertEquals(2, recordCount);
+        @Override
+        public Map add(Map target, String heading, Object value) {
+          target.put(heading, value);
+          return target;
+        }
+
+        @Override
+        public Map finish(Map target) {
+          return target;
+        }
+      };
+
+    List> rows;
+    try (var stream = blue.strategic.parquet.ParquetReader.streamContent(
+      parquetFile.toFile(), blue.strategic.parquet.HydratorSupplier.constantly(hydrator))) {
+      rows = stream.toList();
     }
+
+    assertEquals(2, rows.size());
+
+    var row0 = rows.get(0);
+    assertEquals(tileCoord.z(), ((Number) row0.get("z")).intValue());
+    assertEquals(tileCoord.x(), ((Number) row0.get("x")).intValue());
+    assertEquals(tileCoord.y(), ((Number) row0.get("y")).intValue());
+    assertEquals(tileCoord.hilbertEncoded(), ((Number) row0.get("hilbert")).longValue());
+    assertEquals(999, ((Number) row0.get("archived_tile_bytes")).intValue());
+    assertEquals("layer1", row0.get("layer"));
+    assertEquals(100, ((Number) row0.get("layer_bytes")).intValue());
+    assertEquals(5, ((Number) row0.get("layer_features")).intValue());
+    assertEquals(5, ((Number) row0.get("layer_geometries")).intValue());
+    assertEquals(20, ((Number) row0.get("layer_attr_bytes")).intValue());
+    assertEquals(2, ((Number) row0.get("layer_attr_keys")).intValue());
+    assertEquals(3, ((Number) row0.get("layer_attr_values")).intValue());
+
+    var row1 = rows.get(1);
+    assertEquals("layer2", row1.get("layer"));
+    assertEquals(150, ((Number) row1.get("layer_bytes")).intValue());
+    assertEquals(10, ((Number) row1.get("layer_features")).intValue());
+    assertEquals(30, ((Number) row1.get("layer_attr_bytes")).intValue());
   }
 
 }

From c76eed1a8c9f1b753aeffbb15a132b49e56d5145 Mon Sep 17 00:00:00 2001
From: Ninad Kale 
Date: Fri, 17 Apr 2026 19:15:21 +0530
Subject: [PATCH 7/7] Revert to addNaturalEarthSource, sqlite is not GeoPackage

---
 .../test/java/com/onthegomap/planetiler/PlanetilerTests.java  | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
index d6e4d84331..e11abaa474 100644
--- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
+++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java
@@ -2763,7 +2763,7 @@ public void processFeature(SourceFeature source, FeatureCollector features) {
         }
       })
       .addOsmSource("osm", tempOsm)
-      .addGeoPackageSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite"), null)
+      .addNaturalEarthSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite"))
       .addShapefileSource("shapefile", TestUtils.pathToResource("shapefile.zip"))
       .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg.zip"), null)
       .addGeoJsonSource("geojson", TestUtils.pathToResource("featurecollection.geojson"), null)
@@ -3053,7 +3053,7 @@ private void runWithProfile(Path tempDir, Profile profile, boolean force) {
     Planetiler.create(Arguments.of("tmpdir", tempDir, "force", Boolean.toString(force)))
       .setProfile(profile)
       .addOsmSource("osm", TestUtils.pathToResource("monaco-latest.osm.pbf"))
-      .addGeoPackageSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite"), null)
+      .addNaturalEarthSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite"))
       .addShapefileSource("shapefile", TestUtils.pathToResource("shapefile.zip"))
       .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg.zip"), null)
       .setOutput(tempDir.resolve("output.mbtiles"))