also looking at this
feat: import LAS surfaces and consume maintained source releases
#107
35 files
+1061
-416
| @@ -389,6 +389,18 @@ jobs: | ||
| 389 | 389 | working-directory: ${{ runner.temp }} |
| 390 | 390 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone volumes:import "$GITHUB_WORKSPACE/examples/stockpile/surface.asc" --format asc --metadata "$GITHUB_WORKSPACE/examples/stockpile/metadata.json" --measurement "$GITHUB_WORKSPACE/examples/stockpile/measurement.json" --output "$RUNNER_TEMP/volume-grid" |
| 391 | 391 | working-directory: ${{ runner.temp }} |
| 392 | - name: Qualify copied LAS point-grid source and volume evidence | |
| 393 | run: bun scripts/qualify-las-cli.ts "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$RUNNER_TEMP/las-stockpile-cli" | |
| 394 | - uses: actions/upload-artifact@v4 | |
| 395 | if: always() | |
| 396 | with: | |
| 397 | name: las-stockpile-cli-evidence | |
| 398 | if-no-files-found: ignore | |
| 399 | path: | | |
| 400 | ${{ runner.temp }}/las-stockpile-cli/*.stdout | |
| 401 | ${{ runner.temp }}/las-stockpile-cli/*.stderr | |
| 402 | ${{ runner.temp }}/las-stockpile-cli/receipt.json | |
| 403 | ${{ runner.temp }}/las-stockpile-cli/dataset/** | |
| 392 | 404 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone inspection:plan "$GITHUB_WORKSPACE/examples/inspection.json" |
| 393 | 405 | working-directory: ${{ runner.temp }} |
| 394 | 406 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone inspection:simulate --visit before --output "$RUNNER_TEMP/inspection-before" |
| @@ -18,6 +18,8 @@ This is the `stacksjs/drone` Stacks application. Read `README.md` for implemente | ||
| 18 | 18 | - Imported TypeScript modules explicitly import their dependencies. Browser entry bindings do not leak into modules. |
| 19 | 19 | - `better-dx` supplies shared dev tooling; do not redeclare its tools. Keep Bun's hoisted linker. |
| 20 | 20 | - No `catalog:` dependency ranges. Dependency updates belong to buddy-bot. |
| 21 | - Dependency fixes and upgrades: read `docs/dependency-source-releases.md`. | |
| 22 | Fix the owning source and consume its qualified release; dependency patches are prohibited. | |
| 21 | 23 | - No em-dash or separator en-dash in newly authored visible copy. |
| 22 | 24 | - Before finishing run `./buddy lint`, `./buddy typecheck`, `./buddy test` and relevant browser scenarios. |
| 23 | 25 | - Test behavioral seams: decision input/output, controller lifecycle, real local UDP and protocol fixtures. |
| @@ -243,6 +243,10 @@ it keeps acquisition, reconstruction and measurement separate. | ||
| 243 | 243 | ./buddy volumes:import examples/stockpile/surface.asc --format asc --metadata examples/stockpile/metadata.json --measurement examples/stockpile/measurement.json |
| 244 | 244 | ``` |
| 245 | 245 | |
| 246 | A separate [LAS point-grid adapter](docs/volumes-las.md) constructs declared cell means | |
| 247 | from selected LAS1.2 UTM points and retains the original file and gridding evidence. | |
| 248 | Missing cells and unknown representation error remain explicit. | |
| 249 | ||
| 246 | 250 | Read the [numeric import, integration and audit contract](docs/volumes.md). Examples are synthetic; |
| 247 | 251 | physical surveyed accuracy, raw-point meshing and richer format adapters remain unverified. |
| 248 | 252 | |
| @@ -1,44 +1,63 @@ | ||
| 1 | 1 | import { defineCommand } from '@stacksjs/cli' |
| 2 | import { print, printError } from '@stacksjs/cli/output' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | 3 | import { importGridSurface, importPlySurface } from '../../resources/functions/drone/volumes/import' |
| 4 | 4 | import { importGeoTiffSurface } from '../../resources/functions/drone/volumes/geotiff' |
| 5 | import { readGeoTiffFile } from '../../resources/functions/drone/volumes/files' | |
| 5 | import { readGeoTiffFile, readLasFile } from '../../resources/functions/drone/volumes/files' | |
| 6 | import { importLasSurface } from '../../resources/functions/drone/volumes/las' | |
| 6 | 7 | import { processStockpile, validateSurfaceMetadata, validateVolumeRequest } from '../../resources/functions/drone/volumes/engine' |
| 7 | 8 | import { writeVolumeDataset } from '../../resources/functions/drone/volumes/export' |
| 8 | 9 | import { readMissionFile } from '../../resources/functions/drone/missions/files' |
| 9 | 10 | |
| 11 | const lasDiagnosticCodes = new Set([ | |
| 12 | 'las-surface-input', 'las-surface-budget', 'las-surface-unavailable', | |
| 13 | 'las-surface-no-supported-cells', 'las-source-sha256-mismatch', | |
| 14 | 'las-file-unavailable', 'las-dataset-source-input', 'las-dataset-source-mismatch', | |
| 15 | ]) | |
| 16 | function lasDiagnostic(error: unknown): string { | |
| 17 | const message = error instanceof Error ? error.message : '' | |
| 18 | return lasDiagnosticCodes.has(message) ? message : 'las-import-unavailable' | |
| 19 | } | |
| 20 | ||
| 10 | 21 | function parseDocument(text: string): unknown { |
| 11 | 22 | try { return JSON.parse(text) } |
| 12 | 23 | catch { throw new TypeError('Invalid surface metadata or measurement JSON') } |
| 13 | 24 | } |
| 14 | 25 | export default defineCommand({ |
| 15 | 26 | name: 'volumes:import <surface>', |
| 16 | description: 'Import an ASC grid, indexed ASCII PLY or declared numeric GeoTIFF with a measurement contract', | |
| 27 | description: 'Import ASC, indexed PLY, declared GeoTIFF or a declared LAS point grid with a measurement contract', | |
| 17 | 28 | options: { |
| 18 | '--format <name>': 'Required: asc, ply or geotiff', | |
| 19 | '--metadata <path>': 'Surface metadata for asc/ply, or a wrapped elevation declaration for geotiff', | |
| 29 | '--format <name>': 'Required: asc, ply, geotiff or las', | |
| 30 | '--metadata <path>': 'Surface metadata for asc/ply, or a wrapped declaration for geotiff/las', | |
| 20 | 31 | '--measurement <path>': 'Required JSON: version, id, boundary, base and reference', |
| 21 | 32 | '--output <directory>': 'New private audit dataset', |
| 22 | 33 | }, |
| 23 | 34 | async handle(options, path) { |
| 24 | 35 | try { |
| 25 | 36 | const geotiff = options.format === 'geotiff' |
| 26 | if ((!geotiff && !['asc', 'ply'].includes(options.format ?? '')) || !options.metadata || !options.measurement) throw new TypeError('Specify --format asc|ply, --metadata and --measurement') | |
| 27 | const metadata = geotiff | |
| 37 | const las = options.format === 'las' | |
| 38 | if (!['asc', 'ply', 'geotiff', 'las'].includes(options.format ?? '') || !options.metadata || !options.measurement) throw new TypeError('Specify --format asc|ply|geotiff|las, --metadata and --measurement') | |
| 39 | const metadata = geotiff || las | |
| 28 | 40 | ? await readMissionFile(options.metadata, { preserveBom: true, maxBytes: 32768 }) |
| 29 | 41 | : validateSurfaceMetadata(parseDocument(await readMissionFile(options.metadata))) |
| 30 | 42 | const measurement = parseDocument(await readMissionFile(options.measurement)) |
| 31 | 43 | if (!measurement || typeof measurement !== 'object' || Array.isArray(measurement) || Object.keys(measurement).length !== 5 || Object.keys(measurement).some(key => !['version', 'id', 'boundary', 'base', 'reference'].includes(key))) throw new TypeError('Measurement must declare exactly version, id, boundary, base and reference') |
| 32 | const surface = typeof metadata === 'string' | |
| 44 | const lasBytes = las ? await readLasFile(path) : null | |
| 45 | const lasImport = lasBytes && typeof metadata === 'string' ? await importLasSurface(lasBytes, metadata) : null | |
| 46 | const surface = lasImport ? lasImport.surface : typeof metadata === 'string' | |
| 33 | 47 | ? await importGeoTiffSurface(await readGeoTiffFile(path), metadata) |
| 34 | 48 | : options.format === 'asc' |
| 35 | 49 | ? await importGridSurface(await readMissionFile(path, { preserveBom: true }), metadata) |
| 36 | 50 | : await importPlySurface(await readMissionFile(path, { preserveBom: true }), metadata) |
| 37 | 51 | const request = validateVolumeRequest({ ...measurement, surface }) |
| 38 | 52 | const { integrationPieces, ...report } = processStockpile(request) |
| 39 | const dataset = options.output ? await writeVolumeDataset(options.output, request) : null | |
| 40 | print(JSON.stringify({ report, contributionCount: integrationPieces.length, dataset }, null, 2)) | |
| 53 | const dataset = options.output ? await writeVolumeDataset(options.output, request, lasBytes && typeof metadata === 'string' ? { format: 'las', bytes: lasBytes, declarationJson: metadata } : undefined) : null | |
| 54 | const sourceProcessing = lasImport ? Object.fromEntries(Object.entries(lasImport.processing).filter(([key]) => key !== 'cells')) : null | |
| 55 | await printAsync(JSON.stringify({ report, contributionCount: integrationPieces.length, dataset, ...(sourceProcessing ? { sourceProcessing } : {}) }, null, 2)) | |
| 56 | } | |
| 57 | catch (error) { | |
| 58 | process.exitCode = 1 | |
| 59 | try { await printErrorAsync(options.format === 'las' ? lasDiagnostic(error) : error instanceof Error ? error.message : 'Volume import unavailable') } | |
| 60 | catch { /* Preserve failure when the diagnostic pipe is closed. */ } | |
| 41 | 61 | } |
| 42 | catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } | |
| 43 | 62 | }, |
| 44 | 63 | }) |
bun.lockmodified+4-7
| @@ -25,7 +25,7 @@ | ||
| 25 | 25 | "@tufjs/canonical-json": "2.0.0", |
| 26 | 26 | "stacks": "^0.75.221", |
| 27 | 27 | "ts-images": "0.2.31", |
| 28 | "tuf-js": "6.0.0", | |
| 28 | "tuf-js": "https://github.com/stacksjs/tuf-js/releases/download/sdk-v6.0.1/stacksjs-tuf-js-6.0.1.tgz", | |
| 29 | 29 | }, |
| 30 | 30 | "devDependencies": { |
| 31 | 31 | "@stacksjs/query-builder": "0.75.221", |
| @@ -38,11 +38,8 @@ | ||
| 38 | 38 | "version": "0.75.221", |
| 39 | 39 | }, |
| 40 | 40 | }, |
| 41 | "patchedDependencies": { | |
| 42 | "tuf-js@6.0.0": "patches/tuf-js@6.0.0.patch", | |
| 43 | }, | |
| 44 | 41 | "overrides": { |
| 45 | "@stacksjs/bunpress": "0.2.22", | |
| 42 | "@stacksjs/bunpress": "0.2.25", | |
| 46 | 43 | }, |
| 47 | 44 | "packages": { |
| 48 | 45 | "@buddysh/buddy": ["@buddysh/buddy@0.11.13", "", { "dependencies": { "prompts": "^2.4.2", "ts-pantry": "^0.11.64" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.117.1 <0.123.0" }, "optionalPeers": ["@anthropic-ai/sdk"], "bin": { "buddy": "./dist/bin/cli.js" } }, "sha512-xES8SfgtsXeswuQmFLVj/YV0nOECA4LGl7Oe2ZBqh6otABQY7LT7hAcz9KD/ORvVWtVr+tUt2sA+Vgd5hsm+Eg=="], |
| @@ -93,7 +90,7 @@ | ||
| 93 | 90 | |
| 94 | 91 | "@stacksjs/bun-router": ["@stacksjs/bun-router@0.1.24", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "ts-rate-limiter": "^0.4.2" }, "peerDependencies": { "@stacksjs/stx": ">=0.2.227" }, "optionalPeers": ["@stacksjs/stx"], "bin": { "router": "./dist/cli.js", "bun-router": "./dist/cli.js" } }, "sha512-vXzpmYBe9o0NOVWAZ1kqZJv7VAKTd84rEs3e8e+oEeX3Q4zE078SHTxrYsNcr2n1tb8EwQawaC4YGmFFKrgrzA=="], |
| 95 | 92 | |
| 96 | "@stacksjs/bunpress": ["@stacksjs/bunpress@0.2.22", "", { "dependencies": { "@cwcss/crosswind": "^0.2.16", "@stacksjs/clapp": "^0.2.0", "@stacksjs/stx": "^0.2.550", "@stacksjs/ts-i18n": "^0.1.11", "bunfig": "^0.15.17", "ts-syntax-highlighter": "^0.2.14" }, "peerDependencies": { "@stacksjs/ts-cloud": "^0.7.51" }, "optionalPeers": ["@stacksjs/ts-cloud"], "bin": { "bunpress": "./dist/bin/cli.js" } }, "sha512-xxyLY7rU5v0VGDzv+7AzpFWPMEhMVQn6vZ6FrrAsmb2NShc5MfowO5yig1I3YYdzrSrHH/pDxETUtpC9f8X84A=="], | |
| 93 | "@stacksjs/bunpress": ["@stacksjs/bunpress@0.2.25", "", { "dependencies": { "@cwcss/crosswind": "^0.2.16", "@stacksjs/clapp": "^0.2.0", "@stacksjs/stx": "^0.2.550", "@stacksjs/ts-i18n": "^0.1.11", "bunfig": "^0.15.17", "ts-syntax-highlighter": "^0.2.14" }, "peerDependencies": { "@stacksjs/ts-cloud": "^0.7.51" }, "optionalPeers": ["@stacksjs/ts-cloud"], "bin": { "bunpress": "./dist/bin/cli.js" } }, "sha512-4mzXXQlCgMbkFe4Re6jAcKyGE2hSevpPFBbIytBdrrpEhCDl3VR5UH0v6Ei73wh3jkPAtxWBbJu4B7f72nqrsQ=="], | |
| 97 | 94 | |
| 98 | 95 | "@stacksjs/cache": ["@stacksjs/cache@0.75.221", "", { "dependencies": { "@stacksjs/ts-cache": "^0.1.7" } }, "sha512-D4B2tkif9FISL+5xaMff1Ceu2A1iSlMPLyLbgHGzWuqp2mR8KoB/8Ilpk5uq0Bd/xTf63U9opxkQvKlOr1jkTA=="], |
| 99 | 96 | |
| @@ -465,7 +462,7 @@ | ||
| 465 | 462 | |
| 466 | 463 | "ts-videos": ["ts-videos@0.1.2", "", { "dependencies": { "bunfig": "^0.15.6" }, "optionalDependencies": { "@ts-videos/mp4": "0.1.2", "@ts-videos/webm": "0.1.2" }, "bin": { "ts-videos": "./dist/bin/cli.js" } }, "sha512-J3s3hd6om0y7f7cANlCY72dTYReS/V/D6x3jKUQa358Y1cw5hdggF5LV/6BAYjhMFK5m6Ss7x2GbPyvKmoNimQ=="], |
| 467 | 464 | |
| 468 | "tuf-js": ["tuf-js@6.0.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.3", "@tufjs/models": "5.0.0", "debug": "^4.4.3" } }, "sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ=="], | |
| 465 | "tuf-js": ["@stacksjs/tuf-js@https://github.com/stacksjs/tuf-js/releases/download/sdk-v6.0.1/stacksjs-tuf-js-6.0.1.tgz", { "dependencies": { "@gar/promise-retry": "^1.0.3", "@tufjs/models": "5.0.0", "debug": "^4.4.3" } }, "sha512-E24zEwH9SkFXyxWBUESJ+D0uTHO+4lXOHyYft1tNoEBrNuJe08rUK2xW2rcMYg06WaQOFrcYttm2fDCPK7NWQQ=="], | |
| 469 | 466 | |
| 470 | 467 | "typescript": ["typescript@7.1.0-dev.20261011.1", "", { "optionalDependencies": { "@typescript/typescript-darwin-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-darwin-x64": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-arm": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-linux-x64": "7.1.0-dev.20261011.1", "@typescript/typescript-win32-arm64": "7.1.0-dev.20261011.1", "@typescript/typescript-win32-x64": "7.1.0-dev.20261011.1" }, "bin": { "tsc": "bin/tsc" } }, "sha512-Oestzc61TT5cqxAyVxRsbL+pmMF8pzPlsnLcbCqAWqvC0eYQcp8EJrLWatIgjdUh4RiL79avUCHfM8hdkv07xg=="], |
| 471 | 468 | |
| @@ -102,6 +102,7 @@ const config: BunPressOptions = { | ||
| 102 | 102 | items: [ |
| 103 | 103 | { text: 'Aerial survey collection', link: '/mapping' }, |
| 104 | 104 | { text: 'Known-surface stockpile volumes', link: '/volumes' }, |
| 105 | { text: 'LAS point-grid processing', link: '/volumes-las' }, | |
| 105 | 106 | { text: 'Owned asset inspection', link: '/inspection' }, |
| 106 | 107 | { text: 'Owned agriculture sensing', link: '/agriculture' }, |
| 107 | 108 | { text: 'Open Farming integration', link: '/open-farming' }, |
| @@ -78,6 +78,12 @@ transport cancellation/recovery cases. The [operator contract](cargo-fleet.md#re | ||
| 78 | 78 | retains current authority, a single physical point, original receive deadlines and |
| 79 | 79 | attempted-intake uncertainty. It does not restore a failed vehicle session. |
| 80 | 80 | |
| 81 | `importLasSurface(bytes, declarationJson)` in `volumes/las.ts` produces a declared | |
| 82 | cell-mean surface and separate point-selection/gridding audit. The [LAS contract](volumes-las.md) | |
| 83 | describes profile limits, missing cells and unknown representation error. The server-only | |
| 84 | `writeVolumeDataset(directory, request, { format: 'las', bytes, declarationJson })` verifies | |
| 85 | that the retained original bytes/declaration reproduce the requested surface before publishing. | |
| 86 | ||
| 81 | 87 | ## Target sources |
| 82 | 88 | |
| 83 | 89 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -0,0 +1,43 @@ | ||
| 1 | # Dependency source releases | |
| 2 | ||
| 3 | Dependencies must come from maintained source releases. Dependency patches and | |
| 4 | edits to installed package files are prohibited. Fix and test the owning source | |
| 5 | repository, run its `bun run release:patch` command, then qualify the released | |
| 6 | package in this application with a native frozen install. | |
| 7 | ||
| 8 | ## Maintained TUF client | |
| 9 | ||
| 10 | The `tuf-js` dependency key points to the immutable | |
| 11 | [`@stacksjs/tuf-js` 6.0.1 release archive](https://github.com/stacksjs/tuf-js/releases/tag/sdk-v6.0.1). | |
| 12 | The source is maintained in [stacksjs/tuf-js](https://github.com/stacksjs/tuf-js), | |
| 13 | with release source commit `40a7992babc29eb99c3ee18290ef7aa438421c08`. | |
| 14 | This is a maintained fork of the MIT-licensed upstream client, distinct from the | |
| 15 | upstream npm package. Its original license is retained in the installed package | |
| 16 | and `licenses/tuf-js.LICENSE` in this repository. The | |
| 17 | [SDK source license](https://github.com/stacksjs/tuf-js/blob/sdk-v6.0.1/packages/client/LICENSE) | |
| 18 | retains the original attribution. | |
| 19 | ||
| 20 | The release publishes its package archive, `SHA512SUMS` and `source.json`. | |
| 21 | Verify the immutable release identity, source commit and archive integrity before | |
| 22 | installation. Retain the exact release URL and frozen lockfile. The existing | |
| 23 | `tuf-js` import name resolves to the scoped maintained package through that | |
| 24 | dependency key. The SDK source repository's CLI and conformance harness use the | |
| 25 | scoped name explicitly. | |
| 26 | ||
| 27 | The observer, copied reference time and typed failure contracts are documented | |
| 28 | in [runtime updates](runtime-updates.md#maintained-sdk-persistence-seam). Do not | |
| 29 | remove their verification or persistence behavior during an upgrade. Qualify | |
| 30 | SDK observer cases, enrollment, persistent store/recovery, interrupted updates, | |
| 31 | rollback floors and copied standalone CLI campaigns against the actual release. | |
| 32 | Do not claim physical firmware deployment from these synthetic tests. | |
| 33 | ||
| 34 | ## Documentation engine | |
| 35 | ||
| 36 | BunPress 0.2.25 includes the deployed-path search fix from | |
| 37 | [source commit 096004cc6e7b4800bfd1e90993866c9011ca706d](https://github.com/stacksjs/bunpress/commit/096004cc6e7b4800bfd1e90993866c9011ca706d). | |
| 38 | Consume the source release rather than editing installed files. The public | |
| 39 | search regressions in `tests/unit/docs-search.test.ts`, native documentation | |
| 40 | build and actual browser search selection qualify the application integration. | |
| 41 | ||
| 42 | [Issue #108](https://github.com/stacksjs/drone/issues/108) tracks source-release | |
| 43 | migration and downstream qualification evidence. | |
| @@ -78,7 +78,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa | ||
| 78 | 78 | |---|---|---| |
| 79 | 79 | | Camera tracking and follow | Explicit target selection, image-space loss handling and synthetic calibrated fusion | [Tracking](camera-tracking.md), [fusion](vision.md) | |
| 80 | 80 | | Aerial mapping | Survey planning, synthetic acknowledged capture and dataset export; reconstruction remains open | [Survey collection](mapping.md) | |
| 81 | | Stockpile volumes | Numeric grids/TINs, declared base surfaces, cut/fill and uncertainty; acquisition accuracy is separate | [Volume processing](volumes.md) | | |
| 81 | | Stockpile volumes | Numeric grids/TINs and explicit LAS cell means, declared base surfaces, cut/fill and uncertainty; acquisition accuracy is separate | [Volume processing](volumes.md) | | |
| 82 | 82 | | Infrastructure inspection | Asset-relative modeled capture, explicit observations and repeat-visit comparison | [Inspection](inspection.md) | |
| 83 | 83 | | Farming | Synthetic crop sensing and declared planning previews; agronomy and authenticated app integration remain separate | [Agriculture](agriculture.md), [Open Farming](open-farming.md) | |
| 84 | 84 | | Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) | |
| @@ -23,7 +23,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 23 | 23 | | Antenna pan/tilt and telemetry pointing | Owned selected-target tracking, calibrated geometry, bounded trajectories and modeled mount bench; physical/RF validation pending | Not exposed | [#16](https://github.com/stacksjs/drone/issues/16) | |
| 24 | 24 | | Calibrated 3D visual position and paired target fusion | Owned calibrated lens/crop/mount import, registered-depth localization, explicit GNSS pairing, conservative covariance intersection and sensor-to-control physics; measured calibration, heading, depth adapters and hardware campaigns pending | Not exposed | [#17](https://github.com/stacksjs/drone/issues/17) | |
| 25 | 25 | | Aerial survey, overlap/GSD, photogrammetry collection and georeferencing | Owned clipped survey planning, capture-bound synthetic PNG collection and auditable export; reconstruction, measured calibration and field accuracy pending | Not exposed | [#18](https://github.com/stacksjs/drone/issues/18) | |
| 26 | | LiDAR, surface processing and stockpile volume/uncertainty | Owned known-TIN/cell-grid clipping, explicit base/cut/fill, gaps, conditional bounds, reference comparison, ASC/PLY and audit export; meshing, richer formats and physical survey qualification pending | Not exposed | [#19](https://github.com/stacksjs/drone/issues/19) | | |
| 26 | | LiDAR, surface processing and stockpile volume/uncertainty | Owned known-TIN/cell-grid clipping, explicit base/cut/fill, gaps, conditional bounds, reference comparison, ASC/PLY, declared numeric GeoTIFF and LAS1.2 selected-point cell means with retained source/gridding audit; broader LAS/LAZ, point triangulation and physical survey qualification pending | Not exposed | [#19](https://github.com/stacksjs/drone/issues/19) | | |
| 27 | 27 | | Power lines, turbines, solar, bridges and pipeline inspection | Owned bridge-face route/clearance simulation, captured visible/synthetic thermal imagery, quality, explicit source-bound reviews and revisit comparison; real asset/hardware/defect qualification and richer families pending | Not exposed | [#20](https://github.com/stacksjs/drone/issues/20) | |
| 28 | 28 | | Crop scouting, multispectral calibration and livestock monitoring | Owned raw red/NIR scouting, panel/irradiance model, clipped numeric sampling layer, parcel reports and source-bound explicit animal declarations; measured hardware/field/agronomy and richer interchange pending | Not exposed | [#21](https://github.com/stacksjs/drone/issues/21) | |
| 29 | 29 | | Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) | |
| @@ -618,6 +618,28 @@ | ||
| 618 | 618 | "nativeDependencies": [ |
| 619 | 619 | 18, |
| 620 | 620 | 8 |
| 621 | ], | |
| 622 | "implementationStatus": "processing-implemented-validation-pending", | |
| 623 | "implemented": [ | |
| 624 | "Known-TIN and constant-cell numerical integration with explicit CRS/datum/base, cut/fill, coverage gaps, conditional uncertainty and declared-reference comparison", | |
| 625 | "Bounded ASC/indexed PLY and declared numeric GeoTIFF imports with private completion-marked audit datasets", | |
| 626 | "Declared LAS1.2 UTM formats0-3 to selected-point cell means, count/spread gaps and retained original source/declaration/gridding audit through native CLI" | |
| 627 | ], | |
| 628 | "evidence": [ | |
| 629 | "docs/volumes.md", | |
| 630 | "docs/volumes-las.md", | |
| 631 | "tests/unit/volumes-engine.test.ts", | |
| 632 | "tests/unit/volumes-import.test.ts", | |
| 633 | "tests/unit/volumes-export.test.ts", | |
| 634 | "tests/unit/volumes-geotiff.test.ts", | |
| 635 | "tests/unit/volumes-las.test.ts", | |
| 636 | "tests/unit/volumes-las-cli.test.ts", | |
| 637 | "scripts/qualify-las-cli.ts" | |
| 638 | ], | |
| 639 | "remaining": [ | |
| 640 | "Independent physical survey/classification/calibration and external-reader comparison campaigns", | |
| 641 | "Broader LAS/LAZ/VLR and GeoTIFF profiles, raw-point triangulation, richer base surfaces and tiled/streaming processing", | |
| 642 | "Measured representation/georeferencing uncertainty, acquisition/reconstruction and hardware/storage qualification" | |
| 621 | 643 | ] |
| 622 | 644 | }, |
| 623 | 645 | { |
| @@ -480,7 +480,7 @@ See the [host slot decision](adr/0029-host-slot-health.md). | ||
| 480 | 480 | |
| 481 | 481 | Use a standard TUF repository with `metadata/` and `targets/` directories, |
| 482 | 482 | including consistent-snapshot names when enabled. Verification uses the pinned |
| 483 | [`tuf-js` 6.0.0 client](https://github.com/theupdateframework/tuf-js) for root | |
| 483 | [maintained `@stacksjs/tuf-js` 6.0.1 client](https://github.com/stacksjs/tuf-js/releases/tag/sdk-v6.0.1) for root | |
| 484 | 484 | rotation, signature thresholds, expiration, inter-role versions/hashes and |
| 485 | 485 | target length/hashes. Standalone staging/inspection use a private, randomly |
| 486 | 486 | addressed loopback server supplying bounded local files to the public HTTP |
| @@ -496,10 +496,12 @@ expiry comparisons; malformed or automatically normalized dates are rejected. | ||
| 496 | 496 | |
| 497 | 497 | ### Maintained SDK persistence seam |
| 498 | 498 | |
| 499 | This repository applies `patches/tuf-js@6.0.0.patch` through Bun's root | |
| 500 | `patchedDependencies`. It extends the pinned MIT-licensed client; these are | |
| 501 | repository-maintained options, not options supplied by unmodified upstream 6.0.0. | |
| 502 | The patch retains upstream signature, role and target verification. | |
| 499 | This repository consumes the immutable maintained source release through the | |
| 500 | `tuf-js` dependency key, with no distribution patches. These are maintained fork | |
| 501 | options, not options supplied by unmodified upstream 6.0.0. The source client | |
| 502 | retains upstream signature, role and target verification. See the | |
| 503 | [source release policy](dependency-source-releases.md) for source identity, | |
| 504 | original MIT attribution, release integrity and upgrade qualification. | |
| 503 | 505 | |
| 504 | 506 | `onVerifiedMetadata(role, bytes)` receives an owned byte copy after the SDK |
| 505 | 507 | accepts remote metadata. The SDK awaits it before writing that cache file or |
| @@ -0,0 +1,151 @@ | ||
| 1 | # LAS point-grid stockpile processing | |
| 2 | ||
| 3 | `importLasSurface(bytes, declarationJson)` converts explicitly selected georeferenced | |
| 4 | LAS points into a declared cell-mean surface. The existing [volume engine](volumes.md) | |
| 5 | then integrates that representation against an operator boundary and plane base. This | |
| 6 | separates point acquisition, representation construction and volume computation. | |
| 7 | ||
| 8 | ```sh | |
| 9 | ./buddy volumes:import /path/points.las --format las --metadata /path/las-declaration.json --measurement /path/measurement.json --output /tmp/new-las-volume | |
| 10 | ``` | |
| 11 | ||
| 12 | The same command compiles into the standalone CLI. It never acquires LiDAR, commands | |
| 13 | a vehicle, classifies a stockpile or verifies physical survey accuracy. LAS handler failures | |
| 14 | use bounded format-specific diagnostics without echoing private filenames or input bodies. | |
| 15 | ||
| 16 | LAS errors retain the fixed library codes `las-surface-input`, `las-surface-budget`, | |
| 17 | `las-surface-unavailable`, `las-surface-no-supported-cells`, | |
| 18 | `las-source-sha256-mismatch`, `las-file-unavailable`, `las-dataset-source-input` and | |
| 19 | `las-dataset-source-mismatch`. Other handler failures, including declaration, | |
| 20 | measurement and output filesystem failures, emit `las-import-unavailable` with | |
| 21 | exit 1. Output writes are awaited; an error-pipe failure keeps exit 1. Partial | |
| 22 | output or partial dataset roots can remain after a delivery failure. Accept a | |
| 23 | successful result only after complete streams and exit 0. | |
| 24 | ||
| 25 | ## Explicit declaration | |
| 26 | ||
| 27 | The UTF-8 JSON declaration is limited to 32,768 bytes and code units. Closed objects | |
| 28 | require the following fields. JSON duplicate keys follow native parsing, so the final | |
| 29 | value is validated; a BOM is rejected. | |
| 30 | ||
| 31 | | Field | Meaning | | |
| 32 | |---|---| | |
| 33 | | `version` | `1` | | |
| 34 | | `profile` | `"las12-utm-mean-grid-v1"` | | |
| 35 | | `verticalReference` | `"declared-sidecar"`, with no vertical transformation | | |
| 36 | | `metadata` | Complete existing `SurfaceMetadata`, including exact original LAS SHA256 | | |
| 37 | | `selection.classes` | Nonempty sorted unique class numbers, 0 through 31; no default ground filter | | |
| 38 | | `selection.returns` | `"all"`, `"first"` or `"last"` | | |
| 39 | | `selection.synthetic` | `"exclude"` or `"include"`; withheld points are always excluded | | |
| 40 | | `grid.westM`, `grid.southM` | Declared southwest grid corner in metres | | |
| 41 | | `grid.width`, `grid.height` | Positive integer dimensions, at most 32,768 cells total | | |
| 42 | | `grid.cellSizeM` | Square cell size from 0.00001 through 1,000,000 metres | | |
| 43 | | `grid.minimumPoints` | Minimum selected samples per cell, 1 through 200,000 | | |
| 44 | | `grid.maximumSpreadM` | Maximum selected elevation range per cell, 0 through 100,000,000 metres | | |
| 45 | ||
| 46 | The metadata requires WGS84 UTM and either synthetic analytic or operator-imported | |
| 47 | LiDAR provenance. The header projection must match its EPSG code and metre units. | |
| 48 | The vertical datum is an explicit sidecar declaration. An embedded vertical CRS must | |
| 49 | both match that declaration and belong to the verified metre-up profile below. A matching | |
| 50 | label or explicit metre-unit key cannot override a CRS's actual units or axis direction. | |
| 51 | No coordinate or geoid transformation is performed; datum accuracy remains unverified. | |
| 52 | Unknown classification, calibration and georeferencing status remain unknown. | |
| 53 | ||
| 54 | Points are assigned by `floor((east-west)/size)` and `floor((north-south)/size)`. | |
| 55 | Cells include their west/south boundaries and exclude east/north boundaries. Internal | |
| 56 | edges belong to the east/north cell. Output rows run north to south. No epsilon snap | |
| 57 | changes a point's cell. The importer uses compensated elevation sums and reports | |
| 58 | counts, minima, maxima and means. Cells below the count threshold or above the spread | |
| 59 | threshold remain null. A file with no supported cell is refused rather than reported | |
| 60 | as a zero-volume stockpile. | |
| 61 | ||
| 62 | Selection exclusions are disjoint in this order: withheld, synthetic, class, return, | |
| 63 | outside grid. The audit also retains the complete source class/return histograms. | |
| 64 | Class numbers are source labels, not verified material identities. Last returns do | |
| 65 | not automatically establish bare earth, and class 2 alone must not silently substitute | |
| 66 | the underlying ground for a stockpile top. | |
| 67 | ||
| 68 | ## Representation and uncertainty | |
| 69 | ||
| 70 | Each accepted sample mean represents its entire cell by an explicit modeling assumption. | |
| 71 | The volume engine's covered area refers to these modeled cells, not independently | |
| 72 | verified laser footprints or ground coverage. Duplicate points carry duplicate sample | |
| 73 | weight. Count and within-cell spread do not bound unsampled terrain, systematic bias, | |
| 74 | occlusion or representation error. `pointSamplingCoverageVerified` and | |
| 75 | `physicalAccuracyVerified` remain false; `representationErrorBoundM` remains null. | |
| 76 | No uncertainty is inferred from sample count or spread. | |
| 77 | ||
| 78 | Missing cells are not filled, and covered-domain volumes are not scaled to a full | |
| 79 | boundary total. Conditional declared vertical bounds retain the volume engine's | |
| 80 | assumptions; they do not become an overall physical accuracy bound. The volume report | |
| 81 | keeps acquisition/reconstruction flags false for the integration stage. The separate | |
| 82 | processing audit records `griddingPerformed: true` and its declared cell model. | |
| 83 | ||
| 84 | ## Supported binary profile | |
| 85 | ||
| 86 | This profile follows the header, point layouts and classification flags in the | |
| 87 | [ASPRS LAS 1.2 specification](https://liblas.org/_static/files/specifications/asprs_las_format_v12.pdf), | |
| 88 | particularly its public-header, GeoKey-directory and point-record tables. It accepts | |
| 89 | the 227-byte little-endian 1.2 header and uncompressed point formats 0, 1, 2 and 3 with | |
| 90 | their exact standard record lengths. Scale/offset coordinates, source bounds and | |
| 91 | per-return counts are checked against every point, including excluded points. | |
| 92 | Bounding-box comparison permits only eight binary64 epsilon-scaled rounding units; | |
| 93 | this numerical check is not a survey tolerance. Returns must be from 1 through 5, | |
| 94 | consistent with total returns; signed scan-angle ranks must lie within 90 degrees. | |
| 95 | ||
| 96 | The sole VLR must be an inline `LASF_Projection` GeoKey directory. Required keys are | |
| 97 | projected model, matching WGS84 UTM CRS and metre linear units. Optional point-raster, | |
| 98 | WGS84 geographic CRS and degree angular units have explicit checks. The optional | |
| 99 | vertical-unit key must declare metres. Embedded vertical CRS admission is limited to | |
| 100 | EPSG:3855 (EGM2008), 5701 (ODN), 5703 (NAVD88), 5714 (MSL height), 5773 (EGM96) and | |
| 101 | 5783 (DHHN92). These map to the metre/up axis in the pinned | |
| 102 | [PROJ vertical CRS records](https://github.com/OSGeo/PROJ/blob/5fa31c29f0a806568ed7bc76c1a557604db4a804/data/sql/vertical_crs.sql) | |
| 103 | and [axis definitions](https://github.com/OSGeo/PROJ/blob/5fa31c29f0a806568ed7bc76c1a557604db4a804/data/sql/axis.sql). | |
| 104 | Feet-based, depth-positive, unknown and other unqualified embedded CRSs are refused, | |
| 105 | even if their IDs match the sidecar and a separate key claims metres. The processing | |
| 106 | audit retains the actual embedded ID or its absence, declared datum, metre/up | |
| 107 | interpretation and explicit false transformation/datum-verification flags. Unsupported keys, extra VLRs, referenced parameter tables, custom transforms, | |
| 108 | compressed data, extended headers/records and arbitrary trailers are refused. Zero | |
| 109 | padding before the point table is permitted. Source files are limited to 8 MiB and | |
| 110 | 200,000 points. Coordinates and grid edges must fit within 100,000,000 metres. | |
| 111 | ||
| 112 | GPS fields in formats 1 and 3 retain week-seconds or adjusted standard GPS time with | |
| 113 | an observed range. They are not UTC or validated sensor-clock alignment. RGB, intensity, | |
| 114 | user data, source IDs and scan-direction/key-point flags do not determine the elevation | |
| 115 | mean; original bytes remain available in exported datasets. Colors are not interpreted | |
| 116 | as calibrated imagery. This is an explicit supported profile, not general LAS/LAZ support. | |
| 117 | ||
| 118 | The pure importer synchronously captures native unshared, fixed Uint8Array/Buffer | |
| 119 | visible bytes before its first await. It avoids caller accessors and iteration hooks; | |
| 120 | proxy, shared, resizable and detached storage are refused. The Node-only `readLasFile` | |
| 121 | uses the existing bounded regular-file reader and stays outside the pure drone barrel. | |
| 122 | ||
| 123 | ## Retained evidence and replay | |
| 124 | ||
| 125 | With `--output`, the private completion-marked dataset contains the usual request, | |
| 126 | report, contribution CSV, generated ASC grid and matching sidecar, plus: | |
| 127 | ||
| 128 | - `source.las`: exact original point bytes; | |
| 129 | - `source.declaration.json`: exact declaration used for selection and gridding; | |
| 130 | - `source.gridding.json`: source/declaration hashes, format, time convention, | |
| 131 | exclusions, histograms, grid choices and per-cell statistics. | |
| 132 | ||
| 133 | The writer recomputes gridding and refuses a source/declaration that does not reproduce | |
| 134 | the requested surface before creating a dataset. The completion manifest hashes every | |
| 135 | file and distinguishes original point bytes from the regenerated ASC hash. Replaying | |
| 136 | `request.json`, reimporting the ASC, or importing the retained LAS/declaration preserves | |
| 137 | the numerical report. The normal console summary omits the per-cell table; it remains | |
| 138 | in the audit file and the pure importer result. | |
| 139 | ||
| 140 | The literal eight-point worksheet in `tests/fixtures/volumes/las.ts` was independently | |
| 141 | encoded from the format tables. Five selected points yield north-first means | |
| 142 | `[[3,6],[9,null]]`: three modeled square metres and 18 cubic metres above an explicit | |
| 143 | zero base, with the fourth cell unknown. It is synthetic evidence. Tests cover count, | |
| 144 | spread, class/return/flag selection, formats 0-3, malformed headers/records/projection, | |
| 145 | byte ownership, digest refusal, publication integrity and native replay. The copied | |
| 146 | CLI qualifier retains actual outputs and dataset hashes outside the checkout. | |
| 147 | ||
| 148 | Broader LAS/LAZ and VLR profiles, point triangulation, richer surfaces, streaming/tiled | |
| 149 | processing, measured calibration/classification and independent surveyed comparisons | |
| 150 | remain open under [#19](https://github.com/stacksjs/drone/issues/19). Physical survey | |
| 151 | and storage power-loss qualification are separate from these software checks. | |
| @@ -2,7 +2,9 @@ | ||
| 2 | 2 | |
| 3 | 3 | The owned volume engine processes an existing georeferenced surface. Acquisition, reconstruction |
| 4 | 4 | and volume measurement are separate stages. Survey PNG imagery is not a numerical elevation |
| 5 | surface. This engine neither reconstructs it nor invents terrain from unordered points. | |
| 5 | surface. The engine integrates declared surfaces. A separate [LAS point-grid adapter](volumes-las.md) | |
| 6 | can construct explicit cell means from selected georeferenced points, with representation | |
| 7 | error left unknown; it does not infer a continuous terrain mesh. | |
| 6 | 8 | |
| 7 | 9 | ```sh |
| 8 | 10 | ./buddy volumes:demo --scenario pyramid --output /tmp/new-pyramid-volume |
| @@ -42,7 +44,7 @@ base TIN overlays and inferred toe/base fitting remain future work and are rejec | ||
| 42 | 44 | `volumes:import` requires a source file, its metadata JSON and a measurement JSON containing exactly |
| 43 | 45 | `version`, `id`, `boundary`, `base`, `reference`. For ASC/PLY, the metadata SHA256 must match the exact |
| 44 | 46 | original UTF-8 bytes, including line endings. UTF-8 decoding preserves a BOM so the ASCII parser |
| 45 | rejects it. GeoTIFF uses the separate wrapped declaration below and hashes the original binary bytes. | |
| 47 | rejects it. GeoTIFF and LAS use separate wrapped declarations and hash original binary bytes. | |
| 46 | 48 | |
| 47 | 49 | - AAIGrid `.asc`: square, corner-origin cells (`xllcorner`, `yllcorner`, `cellsize`), north-first |
| 48 | 50 | rows, finite decimal heights and optional finite `NODATA_value`. Each cell is an explicitly |
| @@ -55,13 +57,16 @@ rejects it. GeoTIFF uses the separate wrapped declaration below and hashes the o | ||
| 55 | 57 | - Numeric GeoTIFF: the restrictive `classic-le-f32-utm-area-v1` profile below produces the same |
| 56 | 58 | constant-cell geometry, with the original binary digest retained. This is explicit |
| 57 | 59 | `--format geotiff`; neither file extension nor TIFF magic chooses a format automatically. |
| 60 | - LAS 1.2: the explicit [UTM point-grid profile](volumes-las.md) decodes uncompressed formats | |
| 61 | 0-3, selects declared classes/returns and constructs cell means. Counts/spread rejection | |
| 62 | preserve missing cells. Original point bytes, declaration and per-cell evidence can be retained. | |
| 58 | 63 | - Typed JSON retains either numerical representation directly. Imports are bounded at 8 MiB for |
| 59 | 64 | ASC/PLY; library JSON parsing allows 16 MiB, while the current CLI regular-file reader allows |
| 60 | 65 | 8 MiB. Caps include 32,768 vertices/cells, 16,384 triangles, 64 boundary vertices, eight holes, |
| 61 | 66 | and separate candidate/intersection/contribution budgets. Over-budget work fails explicitly. |
| 62 | 67 | |
| 63 | 68 | Numeric ASC/PLY exports include a matching regenerated sidecar. The original source hash remains |
| 64 | in the retained request and manifest. LAS/LAZ, broader GeoTIFF profiles, raw-point meshing, | |
| 69 | in the retained request and manifest. Broader LAS/LAZ, GeoTIFF profiles, raw-point triangulation, | |
| 65 | 70 | classification and photogrammetric reconstruction require separately qualified adapters. File extensions do not |
| 66 | 71 | establish CRS, vertical datum, ground classification or accuracy. |
| 67 | 72 | |
Renamed from patches/tuf-js.LICENSE. The contents are unchanged.
| @@ -50,7 +50,7 @@ | ||
| 50 | 50 | "@stacksjs/validation": "^0.75.221", |
| 51 | 51 | "stacks": "^0.75.221", |
| 52 | 52 | "ts-images": "0.2.31", |
| 53 | "tuf-js": "6.0.0", | |
| 53 | "tuf-js": "https://github.com/stacksjs/tuf-js/releases/download/sdk-v6.0.1/stacksjs-tuf-js-6.0.1.tgz", | |
| 54 | 54 | "@tufjs/canonical-json": "2.0.0" |
| 55 | 55 | }, |
| 56 | 56 | "workspaces": [ |
| @@ -81,9 +81,6 @@ | ||
| 81 | 81 | "url": "https://github.com/stacksjs/drone/issues" |
| 82 | 82 | }, |
| 83 | 83 | "overrides": { |
| 84 | "@stacksjs/bunpress": "0.2.22" | |
| 85 | }, | |
| 86 | "patchedDependencies": { | |
| 87 | "tuf-js@6.0.0": "patches/tuf-js@6.0.0.patch" | |
| 84 | "@stacksjs/bunpress": "0.2.25" | |
| 88 | 85 | } |
| 89 | 86 | } |
patches/README.mddeleted+0-35
| @@ -1,35 +0,0 @@ | ||
| 1 | # Maintained dependency patches | |
| 2 | ||
| 3 | ## tuf-js 6.0.0 | |
| 4 | ||
| 5 | `tuf-js@6.0.0.patch` applies to the npm 6.0.0 release from | |
| 6 | [the TUF JavaScript client](https://github.com/theupdateframework/tuf-js), upstream | |
| 7 | commit `c30653d07f0c41fbe5a2030d25eca6fd04f8547f`. The original MIT copyright and permission | |
| 8 | notice is retained in `tuf-js.LICENSE` and in the installed package. | |
| 9 | ||
| 10 | The patch adds an awaited, copy-owning `onVerifiedMetadata` observer before the | |
| 11 | SDK writes each newly verified remote metadata file, plus an owned finite | |
| 12 | `referenceTime` for recovery replay. Default callers keep upstream verification rules and the current fixed start | |
| 13 | time. Persistence failure aborts rather than being interpreted as a missing | |
| 14 | remote root or a cache miss under `forceCache`. Discard failed updater instances; | |
| 15 | their in-memory trust set is not rolled back. It does not make cache writes | |
| 16 | transactional or implement a protected trust store. Public operational error | |
| 17 | exports retain causes and identify remote verification, persistence and download | |
| 18 | phases. Verification errors identify an attempted role, not permission to discard | |
| 19 | state. Classify persistence first, including when an observer throws a | |
| 20 | verification error; unknown causes require maintenance rather than an assumed | |
| 21 | invalid-input verdict. | |
| 22 | Wrapper messages never coerce caught values, so opaque observer rejections keep | |
| 23 | their persistence classification and exact cause under cache-preferred refresh. | |
| 24 | ||
| 25 | Bun applies the patch through root `patchedDependencies` and the lockfile. | |
| 26 | Always prepare with `bun patch tuf-js@6.0.0` before editing an installed package, | |
| 27 | then generate changes with `bun patch --commit tuf-js@6.0.0`. Do not edit shared | |
| 28 | package caches. Exclude empty generated `.bun-tag-*` entries from the patch; | |
| 29 | they are installer cache markers rather than package source. For upgrades, review the upstream change against every patch | |
| 30 | hunk and rerun the SDK observer cases, update/enrollment regressions, clean | |
| 31 | frozen installation and copied standalone CLI qualification. Never silently | |
| 32 | carry a patch onto another version. | |
| 33 | ||
| 34 | See `docs/runtime-updates.md` for the trust and clock limits. The extensions are | |
| 35 | maintained here and are not claimed to be unmodified upstream options. | |
patches/tuf-js@6.0.0.patchdeleted+0-255
| @@ -1,255 +0,0 @@ | ||
| 1 | diff --git a/dist/error.d.ts b/dist/error.d.ts | |
| 2 | index 3a42f0441b68f961d6e367efee45b4f4158bbccd..727dd12095b649a8d6203e7eba6d9ebb59be68d6 100644 | |
| 3 | --- a/dist/error.d.ts | |
| 4 | +++ b/dist/error.d.ts | |
| 5 | @@ -6,6 +6,10 @@ export declare class PersistError extends Error { | |
| 6 | } | |
| 7 | export declare class RepositoryError extends Error { | |
| 8 | } | |
| 9 | +export declare class MetadataVerificationError extends Error { | |
| 10 | + readonly role: string; | |
| 11 | + constructor(role: string, message: string, options?: ErrorOptions); | |
| 12 | +} | |
| 13 | export declare class BadVersionError extends RepositoryError { | |
| 14 | } | |
| 15 | export declare class EqualVersionError extends BadVersionError { | |
| 16 | diff --git a/dist/error.js b/dist/error.js | |
| 17 | index 3a3c26a068a953998e16e552faf96b6fbe956f90..e4c07310caa36177ce390e6e3d82dd76178432f4 100644 | |
| 18 | --- a/dist/error.js | |
| 19 | +++ b/dist/error.js | |
| 20 | @@ -17,6 +17,15 @@ exports.PersistError = PersistError; | |
| 21 | class RepositoryError extends Error { | |
| 22 | } | |
| 23 | exports.RepositoryError = RepositoryError; | |
| 24 | +// Repository-maintained public distinction for pure remote metadata validation. | |
| 25 | +class MetadataVerificationError extends Error { | |
| 26 | + role; | |
| 27 | + constructor(role, message, options) { | |
| 28 | + super(message, options); | |
| 29 | + this.role = role; | |
| 30 | + } | |
| 31 | +} | |
| 32 | +exports.MetadataVerificationError = MetadataVerificationError; | |
| 33 | // An error for metadata that contains an invalid version number. | |
| 34 | class BadVersionError extends RepositoryError { | |
| 35 | } | |
| 36 | diff --git a/dist/index.d.ts b/dist/index.d.ts | |
| 37 | index 895fdf4b57c27baf4571e8d06bb93de69166f574..aa1e170b7832c2df49cf2628f77d7895a5f0cbf1 100644 | |
| 38 | --- a/dist/index.d.ts | |
| 39 | +++ b/dist/index.d.ts | |
| 40 | @@ -4,3 +4,4 @@ export { Updater } from './updater'; | |
| 41 | export type { Config } from './config'; | |
| 42 | export type { Fetcher } from './fetcher'; | |
| 43 | export type { UpdaterOptions } from './updater'; | |
| 44 | +export { DownloadError, DownloadHTTPError, MetadataVerificationError, PersistError } from './error'; | |
| 45 | diff --git a/dist/index.js b/dist/index.js | |
| 46 | index 5a83b91f355d88c27615d2bc07310958b18d0142..645728da0d96110d323b0f5e5b2c5f0e839c6fce 100644 | |
| 47 | --- a/dist/index.js | |
| 48 | +++ b/dist/index.js | |
| 49 | @@ -7,3 +7,10 @@ var fetcher_1 = require("./fetcher"); | |
| 50 | Object.defineProperty(exports, "BaseFetcher", { enumerable: true, get: function () { return fetcher_1.BaseFetcher; } }); | |
| 51 | var updater_1 = require("./updater"); | |
| 52 | Object.defineProperty(exports, "Updater", { enumerable: true, get: function () { return updater_1.Updater; } }); | |
| 53 | + | |
| 54 | +// Repository-maintained operational error exports; use causes rather than message matching. | |
| 55 | +var error_1 = require("./error"); | |
| 56 | +Object.defineProperty(exports, "DownloadError", { enumerable: true, get: function () { return error_1.DownloadError; } }); | |
| 57 | +Object.defineProperty(exports, "DownloadHTTPError", { enumerable: true, get: function () { return error_1.DownloadHTTPError; } }); | |
| 58 | +Object.defineProperty(exports, "MetadataVerificationError", { enumerable: true, get: function () { return error_1.MetadataVerificationError; } }); | |
| 59 | +Object.defineProperty(exports, "PersistError", { enumerable: true, get: function () { return error_1.PersistError; } }); | |
| 60 | diff --git a/dist/store.d.ts b/dist/store.d.ts | |
| 61 | index 20f835c29dee82ea3e61c775c83d8a34fad787dc..3b9c2f88cbe9082b45927ccabccb44f1d56d8739 100644 | |
| 62 | --- a/dist/store.d.ts | |
| 63 | +++ b/dist/store.d.ts | |
| 64 | @@ -2,7 +2,7 @@ import { Metadata, Root, Snapshot, Targets, Timestamp } from '@tufjs/models'; | |
| 65 | export declare class TrustedMetadataStore { | |
| 66 | private trustedSet; | |
| 67 | private referenceTime; | |
| 68 | - constructor(rootData: Buffer); | |
| 69 | + constructor(rootData: Buffer, referenceTime?: Date); | |
| 70 | get root(): Metadata<Root>; | |
| 71 | get timestamp(): Metadata<Timestamp> | undefined; | |
| 72 | get snapshot(): Metadata<Snapshot> | undefined; | |
| 73 | diff --git a/dist/store.js b/dist/store.js | |
| 74 | index 1b1669029a8db00b7cc594ef202984f3d243a0e4..0ef50b3d8b599b7d49392d7d8c5f63d3c6f0ecce 100644 | |
| 75 | --- a/dist/store.js | |
| 76 | +++ b/dist/store.js | |
| 77 | @@ -6,9 +6,9 @@ const error_1 = require("./error"); | |
| 78 | class TrustedMetadataStore { | |
| 79 | trustedSet = {}; | |
| 80 | referenceTime; | |
| 81 | - constructor(rootData) { | |
| 82 | + constructor(rootData, referenceTime = new Date()) { | |
| 83 | // Client workflow 5.1: record fixed update start time | |
| 84 | - this.referenceTime = new Date(); | |
| 85 | + this.referenceTime = new Date(referenceTime.getTime()); | |
| 86 | // Client workflow 5.2: load trusted root metadata | |
| 87 | this.loadTrustedRoot(rootData); | |
| 88 | } | |
| 89 | diff --git a/dist/updater.d.ts b/dist/updater.d.ts | |
| 90 | index 883df0fe6a6628ef5d16bdb0de17c0850d7d19ec..0fe93d96f9a7912c6708bfdcd6f8bcc7330c9e2e 100644 | |
| 91 | --- a/dist/updater.d.ts | |
| 92 | +++ b/dist/updater.d.ts | |
| 93 | @@ -9,6 +9,10 @@ export interface UpdaterOptions { | |
| 94 | fetcher?: Fetcher; | |
| 95 | forceCache?: boolean; | |
| 96 | config?: Partial<Config>; | |
| 97 | + /** Repository-maintained extension: called after verification, awaited before cache persistence. */ | |
| 98 | + onVerifiedMetadata?: (role: string, bytes: Buffer) => void | Promise<void>; | |
| 99 | + /** Repository-maintained extension: copied fixed start time for recovery replay, not a trusted clock source. */ | |
| 100 | + referenceTime?: Date; | |
| 101 | } | |
| 102 | export declare class Updater { | |
| 103 | private dir; | |
| 104 | @@ -19,6 +23,7 @@ export declare class Updater { | |
| 105 | private trustedSet; | |
| 106 | private config; | |
| 107 | private fetcher; | |
| 108 | + private onVerifiedMetadata; | |
| 109 | constructor(options: UpdaterOptions); | |
| 110 | refresh(): Promise<void>; | |
| 111 | getTargetInfo(targetPath: string): Promise<TargetFile | undefined>; | |
| 112 | @@ -31,5 +36,6 @@ export declare class Updater { | |
| 113 | private loadTargets; | |
| 114 | private preorderDepthFirstWalk; | |
| 115 | private generateTargetPath; | |
| 116 | + private verifyMetadata; | |
| 117 | private persistMetadata; | |
| 118 | } | |
| 119 | diff --git a/dist/updater.js b/dist/updater.js | |
| 120 | index 94a7658155740fee3f3b7b32937905cc7f791e90..ce62cfdc83eeec41479089aa301cbea250d8d116 100644 | |
| 121 | --- a/dist/updater.js | |
| 122 | +++ b/dist/updater.js | |
| 123 | @@ -57,6 +57,7 @@ class Updater { | |
| 124 | trustedSet; | |
| 125 | config; | |
| 126 | fetcher; | |
| 127 | + onVerifiedMetadata; | |
| 128 | constructor(options) { | |
| 129 | const { metadataDir, metadataBaseUrl, targetDir, targetBaseUrl, fetcher, config, } = options; | |
| 130 | this.dir = metadataDir; | |
| 131 | @@ -64,8 +65,16 @@ class Updater { | |
| 132 | this.targetDir = targetDir; | |
| 133 | this.targetBaseUrl = targetBaseUrl; | |
| 134 | this.forceCache = options.forceCache ?? false; | |
| 135 | + const referenceTime = options.referenceTime ?? new Date(); | |
| 136 | + if (!(referenceTime instanceof Date) || !Number.isFinite(referenceTime.getTime())) { | |
| 137 | + throw new error_1.ValueError('Invalid fixed update reference time'); | |
| 138 | + } | |
| 139 | + if (options.onVerifiedMetadata !== undefined && typeof options.onVerifiedMetadata !== 'function') { | |
| 140 | + throw new error_1.ValueError('Invalid verified metadata observer'); | |
| 141 | + } | |
| 142 | + this.onVerifiedMetadata = options.onVerifiedMetadata; | |
| 143 | const data = this.loadLocalMetadata(models_1.MetadataKind.Root); | |
| 144 | - this.trustedSet = new store_1.TrustedMetadataStore(data); | |
| 145 | + this.trustedSet = new store_1.TrustedMetadataStore(data, new Date(referenceTime.getTime())); | |
| 146 | this.config = { ...config_1.defaultConfig, ...config }; | |
| 147 | const userAgent = config?.userAgent | |
| 148 | ? `${config.userAgent} tuf-js/${package_json_1.version}` | |
| 149 | @@ -92,6 +101,10 @@ class Updater { | |
| 150 | await this.loadTimestamp({ checkRemote: false }); | |
| 151 | } | |
| 152 | catch (error) { | |
| 153 | + // A failed persistence observer/cache write is not a cache miss. | |
| 154 | + if (error instanceof error_1.PersistError) { | |
| 155 | + throw error; | |
| 156 | + } | |
| 157 | await this.loadRoot(); | |
| 158 | await this.loadTimestamp(); | |
| 159 | } | |
| 160 | @@ -173,9 +186,9 @@ class Updater { | |
| 161 | // Client workflow 5.3.3: download new root metadata file | |
| 162 | const bytesData = await this.fetcher.downloadBytes(rootUrl, this.config.rootMaxLength); | |
| 163 | // Client workflow 5.3.4 - 5.4.7 | |
| 164 | - this.trustedSet.updateRoot(bytesData); | |
| 165 | + this.verifyMetadata(models_1.MetadataKind.Root, () => this.trustedSet.updateRoot(bytesData)); | |
| 166 | // Client workflow 5.3.8: persist root metadata file | |
| 167 | - this.persistMetadata(models_1.MetadataKind.Root, bytesData); | |
| 168 | + await this.persistMetadata(models_1.MetadataKind.Root, bytesData); | |
| 169 | } | |
| 170 | catch (error) { | |
| 171 | if (error instanceof error_1.DownloadHTTPError) { | |
| 172 | @@ -210,7 +223,7 @@ class Updater { | |
| 173 | const bytesData = await this.fetcher.downloadBytes(timestampUrl, this.config.timestampMaxLength); | |
| 174 | try { | |
| 175 | // Client workflow 5.4.2 - 5.4.4 | |
| 176 | - this.trustedSet.updateTimestamp(bytesData); | |
| 177 | + this.verifyMetadata(models_1.MetadataKind.Timestamp, () => this.trustedSet.updateTimestamp(bytesData)); | |
| 178 | } | |
| 179 | catch (error) { | |
| 180 | // If new timestamp version is same as current, discardd the new one. | |
| 181 | @@ -222,7 +235,7 @@ class Updater { | |
| 182 | throw error; | |
| 183 | } | |
| 184 | // Client workflow 5.4.5: persist timestamp metadata | |
| 185 | - this.persistMetadata(models_1.MetadataKind.Timestamp, bytesData); | |
| 186 | + await this.persistMetadata(models_1.MetadataKind.Timestamp, bytesData); | |
| 187 | } | |
| 188 | // Load local and remote snapshot metadata. | |
| 189 | // Client workflow 5.5: update snapshot role | |
| 190 | @@ -246,12 +259,12 @@ class Updater { | |
| 191 | // Client workflow 5.5.1: download snapshot metadata file | |
| 192 | const bytesData = await this.fetcher.downloadBytes(snapshotUrl, maxLength); | |
| 193 | // Client workflow 5.5.2 - 5.5.6 | |
| 194 | - this.trustedSet.updateSnapshot(bytesData); | |
| 195 | + this.verifyMetadata(models_1.MetadataKind.Snapshot, () => this.trustedSet.updateSnapshot(bytesData)); | |
| 196 | // Client workflow 5.5.7: persist snapshot metadata file | |
| 197 | - this.persistMetadata(models_1.MetadataKind.Snapshot, bytesData); | |
| 198 | + await this.persistMetadata(models_1.MetadataKind.Snapshot, bytesData); | |
| 199 | } | |
| 200 | catch (error) { | |
| 201 | - throw new error_1.RuntimeError(`Unable to load snapshot metadata error ${error}`); | |
| 202 | + throw new error_1.RuntimeError('Unable to load snapshot metadata', { cause: error }); | |
| 203 | } | |
| 204 | } | |
| 205 | } | |
| 206 | @@ -282,12 +295,12 @@ class Updater { | |
| 207 | // Client workflow 5.6.1: download targets metadata file | |
| 208 | const bytesData = await this.fetcher.downloadBytes(metadataUrl, maxLength); | |
| 209 | // Client workflow 5.6.2 - 5.6.6 | |
| 210 | - this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole); | |
| 211 | + this.verifyMetadata(role, () => this.trustedSet.updateDelegatedTargets(bytesData, role, parentRole)); | |
| 212 | // Client workflow 5.6.7: persist targets metadata file | |
| 213 | - this.persistMetadata(role, bytesData); | |
| 214 | + await this.persistMetadata(role, bytesData); | |
| 215 | } | |
| 216 | catch (error) { | |
| 217 | - throw new error_1.RuntimeError(`Unable to load targets error ${error}`); | |
| 218 | + throw new error_1.RuntimeError('Unable to load targets metadata', { cause: error }); | |
| 219 | } | |
| 220 | } | |
| 221 | return this.trustedSet.getRole(role); | |
| 222 | @@ -358,15 +371,31 @@ class Updater { | |
| 223 | const filePath = encodeURIComponent(targetInfo.path); | |
| 224 | return path.join(this.targetDir, filePath); | |
| 225 | } | |
| 226 | - persistMetadata(metaDataName, bytesData) { | |
| 227 | + verifyMetadata(role, operation) { | |
| 228 | + try { | |
| 229 | + return operation(); | |
| 230 | + } | |
| 231 | + catch (error) { | |
| 232 | + // Equal timestamp versions retain the existing normal no-new-metadata path. | |
| 233 | + if (error instanceof error_1.EqualVersionError) { | |
| 234 | + throw error; | |
| 235 | + } | |
| 236 | + throw new error_1.MetadataVerificationError(role, `Failed to verify metadata ${encodeURIComponent(role)}`, { cause: error }); | |
| 237 | + } | |
| 238 | + } | |
| 239 | + async persistMetadata(metaDataName, bytesData) { | |
| 240 | const encodedName = encodeURIComponent(metaDataName); | |
| 241 | try { | |
| 242 | + // The observer owns its copy and must finish before this cache or the next role advances. | |
| 243 | + if (this.onVerifiedMetadata) { | |
| 244 | + await this.onVerifiedMetadata(metaDataName, Buffer.from(bytesData)); | |
| 245 | + } | |
| 246 | const filePath = path.join(this.dir, `${encodedName}.json`); | |
| 247 | log('WRITE %s', filePath); | |
| 248 | fs.writeFileSync(filePath, bytesData.toString('utf8')); | |
| 249 | } | |
| 250 | catch (error) { | |
| 251 | - throw new error_1.PersistError(`Failed to persist metadata ${encodedName} error: ${error}`); | |
| 252 | + throw new error_1.PersistError(`Failed to persist metadata ${encodedName}`, { cause: error }); | |
| 253 | } | |
| 254 | } | |
| 255 | } | |
| @@ -10,7 +10,7 @@ const scenario = state<ApplicationScenario>('nominal') | ||
| 10 | 10 | const mode = state('test') |
| 11 | 11 | const authorization = state('none') |
| 12 | 12 | const run = state<ApplicationRun | null>(null) |
| 13 | const report = state<ApplicationReport | null>(null) | |
| 13 | const applicationReport = state<ApplicationReport | null>(null) | |
| 14 | 14 | const busy = state(false) |
| 15 | 15 | const error = state('') |
| 16 | 16 | const epoch = state(600) |
| @@ -22,9 +22,9 @@ const validEpoch = derived(() => !run() || Number.isSafeInteger(Number(epoch())) | ||
| 22 | 22 | const input = derived(() => validEpoch() ? run()?.inputs.find(row => row.atMs === Number(epoch())) ?? null : null) |
| 23 | 23 | const decision = derived(() => validEpoch() ? run()?.decisions.find(row => row.atMs === Number(epoch())) ?? null : null) |
| 24 | 24 | const probe = derived(() => validEpoch() ? run()?.probes.find(row => row.atMs === Number(epoch())) ?? null : null) |
| 25 | const cell = derived(() => Number.isInteger(Number(cellIndex())) && Number(cellIndex()) >= 0 ? report()?.cells[Number(cellIndex())] ?? null : null) | |
| 25 | const cell = derived(() => Number.isInteger(Number(cellIndex())) && Number(cellIndex()) >= 0 ? applicationReport()?.cells[Number(cellIndex())] ?? null : null) | |
| 26 | 26 | const doseMaximum = derived(() => Math.max(1, ...config().prescription.map(zone => zone.ratePerHa))) |
| 27 | const preview = derived(() => report() ? applicationReportPreview(report()!, doseMaximum()) : '') | |
| 27 | const preview = derived(() => applicationReport() ? applicationReportPreview(applicationReport()!, doseMaximum()) : '') | |
| 28 | 28 | const rateMaximum = derived(() => Math.max(config().calibration.maxRatePerSecond, ...(run()?.decisions.map(row => Math.max(row.targetPerSecond, row.measuredPerSecond ?? 0)) ?? []))) |
| 29 | 29 | const ratePlot = derived(() => applicationRatePlot(run()?.decisions ?? [], rateMaximum())) |
| 30 | 30 | function quantity(value: number | null | undefined, digits = 6) { return value === null || value === undefined ? 'unavailable' : value.toFixed(digits).replace(/^-0(\.0+)$/, '0$1') } |
renamed throughout · 16 hidden lines
| @@ -33,14 +33,14 @@ const pending = useTimeout(() => { renamed throughout | ||
| 33 | 33 | try { |
| 34 | 34 | const result = simulateApplication({ config: exampleApplicationConfig(implement()), scenario: scenario(), testMode: mode() === 'test', authorizeImplement: authorization() === 'grant' }) |
| 35 | 35 | const audited = processApplication(result) |
| 36 | run.set(result); report.set(audited) | |
| 36 | run.set(result); applicationReport.set(audited) | |
| 37 | 37 | void nextTick(() => { if (run() === result) epoch.set(result.final.atMs) }) |
| 38 | 38 | } |
| 39 | 39 | catch (failure) { error.set(failure instanceof Error ? failure.message : 'Application simulation failed') } |
| 40 | 40 | finally { busy.set(false) } |
| 41 | 41 | }, 32) |
| 42 | 42 | pending.stop() |
| 43 | function clear() { pending.stop(); run.set(null); report.set(null); error.set(''); busy.set(false); epoch.set(600); cellIndex.set(0) } | |
| 43 | function clear() { pending.stop(); run.set(null); applicationReport.set(null); error.set(''); busy.set(false); epoch.set(600); cellIndex.set(0) } | |
| 44 | 44 | effect(() => { implement(); scenario(); mode(); authorization(); clear() }) |
| 45 | 45 | onDestroy(() => pending.stop()) |
| 46 | 46 | function execute() { clear(); busy.set(true); pending.start() } |
| @@ -61,10 +61,10 @@ function execute() { clear(); busy.set(true); pending.start() } | ||
| 61 | 61 | <p :if="busy()" role="status" class="mt-5 text-emerald-200 text-sm">Running sensor, estimator, mission, metering and exact coverage processing.</p> |
| 62 | 62 | <p :if="error()" role="alert" class="mt-5 p-4 break-words text-red-200 border border-red-400 rounded">{{ error() }}</p> |
| 63 | 63 | <p :if="!run() && !busy()" class="mt-7 py-5 text-neutral-400 border-neutral-700 border-t">No application recording. Dry test and no grant are the defaults. Metered discharge requires both the metered model and a separate grant.</p> |
| 64 | <div :if="run() && report()" class="mt-8 space-y-8"> | |
| 64 | <div :if="run() && applicationReport()" class="mt-8 space-y-8"> | |
| 65 | 65 | <section aria-labelledby="application-result" class="py-5 border-neutral-700 border-t"> |
| 66 | 66 | <h2 id="application-result" class="font-semibold text-lg">{{ run()?.status }} / {{ run()?.reason }}</h2> |
| 67 | <dl class="grid gap-5 grid-cols-2 sm:grid-cols-4 mt-5 text-sm"><div><dt class="text-neutral-400">Modeled emitted</dt><dd class="mt-1 font-mono">{{ quantity(report()?.emittedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Treatable field</dt><dd class="mt-1 font-mono">{{ quantity(report()?.treatableAreaM2, 2) }} m2</dd></div><div><dt class="text-neutral-400">Covered / missed</dt><dd class="mt-1 font-mono">{{ quantity(report()?.coveredAreaM2, 2) }} / {{ quantity(report()?.missedAreaM2, 2) }} m2</dd></div><div><dt class="text-neutral-400">Distinct-pass overlap</dt><dd class="mt-1 font-mono">{{ quantity(report()?.overlapAreaM2, 3) }} m2</dd></div></dl> | |
| 67 | <dl class="grid gap-5 grid-cols-2 sm:grid-cols-4 mt-5 text-sm"><div><dt class="text-neutral-400">Modeled emitted</dt><dd class="mt-1 font-mono">{{ quantity(applicationReport()?.emittedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Treatable field</dt><dd class="mt-1 font-mono">{{ quantity(applicationReport()?.treatableAreaM2, 2) }} m2</dd></div><div><dt class="text-neutral-400">Covered / missed</dt><dd class="mt-1 font-mono">{{ quantity(applicationReport()?.coveredAreaM2, 2) }} / {{ quantity(applicationReport()?.missedAreaM2, 2) }} m2</dd></div><div><dt class="text-neutral-400">Distinct-pass overlap</dt><dd class="mt-1 font-mono">{{ quantity(applicationReport()?.overlapAreaM2, 3) }} m2</dd></div></dl> | |
| 68 | 68 | <p class="mt-4 break-words text-neutral-400 text-sm">Material {{ config().material.id }}, batch {{ config().material.batch }}. Calibration {{ config().calibration.id }} is synthetic. Route completion does not establish complete field coverage.</p> |
| 69 | 69 | </section> |
| 70 | 70 | <section aria-labelledby="application-rate" class="min-w-0"> |
| @@ -78,9 +78,9 @@ function execute() { clear(); busy.set(true); pending.start() } | ||
| 78 | 78 | </section> |
| 79 | 79 | <section aria-labelledby="application-dose" class="grid gap-6 md:grid-cols-2 pt-7 border-neutral-700 border-t"> |
| 80 | 80 | <div class="min-w-0"><h2 id="application-dose" class="font-semibold text-lg">Retained dose cells</h2><img :src="preview()" width="320" height="320" alt="North-up exact modeled deposited dose cells; red is zero, green reaches the declared maximum, grey is unavailable treatable support" class="block object-contain mt-4 h-80 w-full bg-neutral-900 border border-neutral-700 rounded [image-rendering:pixelated]"><p class="mt-3 text-neutral-400 text-xs">North is up. Fixed colour scale: 0 to {{ doseMaximum() }} {{ unit() }}/ha and above. Grey is unavailable treatable support. Uniform rectangular deposition is a declared model, not measured field dose.</p></div> |
| 81 | <div class="min-w-0"><label for="application-cell" class="block mb-2 text-sm">Cell index, north to south, west to east</label><input id="application-cell" type="number" min="0" :max="(report()?.cells.length || 1) - 1" step="1" x-model.number="cellIndex" class="px-3 py-2 w-32 bg-neutral-900 border border-neutral-600 rounded"><p :if="!cell()" role="alert" class="mt-3 text-red-200 text-sm">Choose a retained cell index.</p><dl :if="cell()" class="mt-5 space-y-3 break-words text-sm"><div><dt class="text-neutral-400">Cell / north-east bounds</dt><dd class="font-mono">{{ cell()?.id }} / {{ vector(cell()?.boundsM) }} m</dd></div><div><dt class="text-neutral-400">Dose / prescribed amount</dt><dd class="font-mono">{{ quantity(cell()?.dosePerHa, 2) }} {{ unit() }}/ha / {{ quantity(cell()?.prescribedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Field support / excluded support</dt><dd class="font-mono">{{ quantity(cell()?.areaM2, 3) }} / {{ quantity(cell()?.excludedAreaM2, 3) }} m2</dd></div><div><dt class="text-neutral-400">Covered / distinct-pass overlap</dt><dd class="font-mono">{{ quantity(cell()?.coveredAreaM2, 3) }} / {{ quantity(cell()?.overlapAreaM2, 3) }} m2</dd></div></dl></div> | |
| 81 | <div class="min-w-0"><label for="application-cell" class="block mb-2 text-sm">Cell index, north to south, west to east</label><input id="application-cell" type="number" min="0" :max="(applicationReport()?.cells.length || 1) - 1" step="1" x-model.number="cellIndex" class="px-3 py-2 w-32 bg-neutral-900 border border-neutral-600 rounded"><p :if="!cell()" role="alert" class="mt-3 text-red-200 text-sm">Choose a retained cell index.</p><dl :if="cell()" class="mt-5 space-y-3 break-words text-sm"><div><dt class="text-neutral-400">Cell / north-east bounds</dt><dd class="font-mono">{{ cell()?.id }} / {{ vector(cell()?.boundsM) }} m</dd></div><div><dt class="text-neutral-400">Dose / prescribed amount</dt><dd class="font-mono">{{ quantity(cell()?.dosePerHa, 2) }} {{ unit() }}/ha / {{ quantity(cell()?.prescribedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Field support / excluded support</dt><dd class="font-mono">{{ quantity(cell()?.areaM2, 3) }} / {{ quantity(cell()?.excludedAreaM2, 3) }} m2</dd></div><div><dt class="text-neutral-400">Covered / distinct-pass overlap</dt><dd class="font-mono">{{ quantity(cell()?.coveredAreaM2, 3) }} / {{ quantity(cell()?.overlapAreaM2, 3) }} m2</dd></div></dl></div> | |
| 82 | 82 | </section> |
| 83 | <section aria-labelledby="application-closure" class="pt-7 border-neutral-700 border-t"><h2 id="application-closure" class="font-semibold text-lg">Shutdown and material balance</h2><p class="mt-3 text-sm">Modeled closure {{ run()?.shutdown.closureVerified ? 'verified in the independent tail' : 'unverified' }}. Request {{ run()?.shutdown.requestedAtMs }} ms, final probe {{ run()?.shutdown.finalAtMs }} ms. Emitted after request: {{ quantity(run()?.shutdown.emittedAfterRequest) }} {{ unit() }}.</p><dl class="grid gap-4 grid-cols-2 sm:grid-cols-4 mt-5 text-sm"><div><dt class="text-neutral-400">Treatable field</dt><dd class="font-mono">{{ quantity(report()?.inFieldAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Excluded support</dt><dd class="font-mono">{{ quantity(report()?.excludedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Outside field</dt><dd class="font-mono">{{ quantity(report()?.outsideAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Still in flight</dt><dd class="font-mono">{{ quantity(report()?.inFlightAmount) }} {{ unit() }}</dd></div></dl><p class="mt-4 text-neutral-400 text-xs">Conservation residual {{ quantity(report()?.conservationError, 10) }} {{ unit() }}. Host measured totals and independent material truth are separate evidence.</p></section> | |
| 83 | <section aria-labelledby="application-closure" class="pt-7 border-neutral-700 border-t"><h2 id="application-closure" class="font-semibold text-lg">Shutdown and material balance</h2><p class="mt-3 text-sm">Modeled closure {{ run()?.shutdown.closureVerified ? 'verified in the independent tail' : 'unverified' }}. Request {{ run()?.shutdown.requestedAtMs }} ms, final probe {{ run()?.shutdown.finalAtMs }} ms. Emitted after request: {{ quantity(run()?.shutdown.emittedAfterRequest) }} {{ unit() }}.</p><dl class="grid gap-4 grid-cols-2 sm:grid-cols-4 mt-5 text-sm"><div><dt class="text-neutral-400">Treatable field</dt><dd class="font-mono">{{ quantity(applicationReport()?.inFieldAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Excluded support</dt><dd class="font-mono">{{ quantity(applicationReport()?.excludedAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Outside field</dt><dd class="font-mono">{{ quantity(applicationReport()?.outsideAmount) }} {{ unit() }}</dd></div><div><dt class="text-neutral-400">Still in flight</dt><dd class="font-mono">{{ quantity(applicationReport()?.inFlightAmount) }} {{ unit() }}</dd></div></dl><p class="mt-4 text-neutral-400 text-xs">Conservation residual {{ quantity(applicationReport()?.conservationError, 10) }} {{ unit() }}. Host measured totals and independent material truth are separate evidence.</p></section> | |
| 84 | 84 | </div> |
| 85 | 85 | <section aria-labelledby="application-requirements" class="mt-8 pt-7 border-neutral-700 border-t"><h2 id="application-requirements" class="font-semibold text-lg">Declared constraints</h2><p class="mt-3 break-words text-neutral-400 text-sm">Wind north/east {{ vector(config().weather.windMps) }} m/s, maximum {{ config().weatherLimits.maxWindMps }} m/s. Temperature {{ config().weather.temperatureC }} C within {{ config().weatherLimits.minTemperatureC }}..{{ config().weatherLimits.maxTemperatureC }} C; humidity {{ config().weather.humidityPercent }}%, minimum {{ config().weatherLimits.minHumidityPercent }}%. Inversion must be absent. Weather age limit {{ config().weatherLimits.maxAgeMs }} ms.</p><p class="mt-3 break-words text-neutral-400 text-sm">Operator {{ config().localRequirements.operatorId }}. Jurisdiction {{ config().localRequirements.jurisdiction ?? 'unspecified' }}. Local requirements remain {{ config().localRequirements.status }}; physical trial eligible: false. Material-specific distribution, independent catch/scale calibration, drift/weather and local operating requirements need physical qualification.</p></section> |
| 86 | 86 | </section> |
| @@ -10,7 +10,7 @@ const busy = state(false) | ||
| 10 | 10 | const error = state('') |
| 11 | 11 | const offset = state(0) |
| 12 | 12 | const replay = derived(() => current() ? createDiagnosticsReplay(current()!).advanceTo(offset()) : null) |
| 13 | const report = derived(() => current() ? diagnoseDiagnostics(current()!) : null) | |
| 13 | const diagnosticReport = derived(() => current() ? diagnoseDiagnostics(current()!) : null) | |
| 14 | 14 | const duration = derived(() => current()?.footer?.atMs ?? current()?.events.at(-1)?.atMs ?? 0) |
| 15 | 15 | const rows = derived(() => (current()?.events ?? []).filter(row => row.atMs <= offset()).slice(-24)) |
| 16 | 16 | let revision = 0 |
| @@ -63,7 +63,7 @@ onDestroy(() => { revision++ }) | ||
| 63 | 63 | <p class="mt-5 text-neutral-400 text-sm">Observed faults: <span class="font-mono text-neutral-100" data-replay-faults>{{ replay()?.health.join(', ') || 'none at this epoch' }}</span></p> |
| 64 | 64 | <h3 class="mt-8 text-neutral-400 text-sm">Last 24 records at this epoch</h3><div class="overflow-x-auto mt-3"><table class="w-full text-left text-xs"><thead><tr class="text-neutral-400 border-b border-neutral-700"><th scope="col" class="pr-4 py-3">Seq</th><th scope="col" class="pr-4 py-3">Time / ms</th><th scope="col" class="py-3">Event</th></tr></thead><tbody><tr :for="row in rows()" class="border-b border-neutral-800"><td class="pr-4 py-3 font-mono">{{ row.sequence }}</td><td class="pr-4 py-3 font-mono">{{ row.atMs }}</td><td class="py-3 font-mono">{{ row.event.kind === 'runtime' ? row.event.direction + ' / ' + row.event.frame.kind : row.event.kind === 'health' ? row.event.code : 'board tick' }}</td></tr></tbody></table></div> |
| 65 | 65 | </div> |
| 66 | <aside class="min-w-0"><h2 class="font-semibold text-lg">Recording checks</h2><p class="mt-2 text-neutral-400 text-sm">These checks diagnose the file. Hardware commissioning remains blocked.</p><dl class="mt-5 text-sm"><div :for="check in report()?.checks || []" class="py-3 border-b border-neutral-700"><dt class="flex gap-3 justify-between"><span>{{ check.id }}</span><span class="font-mono">{{ check.state }}</span></dt><dd class="mt-2 break-words text-neutral-400 text-xs">{{ check.detail }}</dd></div></dl> | |
| 66 | <aside class="min-w-0"><h2 class="font-semibold text-lg">Recording checks</h2><p class="mt-2 text-neutral-400 text-sm">These checks diagnose the file. Hardware commissioning remains blocked.</p><dl class="mt-5 text-sm"><div :for="check in diagnosticReport()?.checks || []" class="py-3 border-b border-neutral-700"><dt class="flex gap-3 justify-between"><span>{{ check.id }}</span><span class="font-mono">{{ check.state }}</span></dt><dd class="mt-2 break-words text-neutral-400 text-xs">{{ check.detail }}</dd></div></dl> | |
| 67 | 67 | <details class="mt-6"><summary class="text-sm cursor-pointer">Firmware and configuration provenance</summary><dl class="mt-4 space-y-4 text-xs"><div><dt class="text-neutral-400">Firmware SHA-256</dt><dd class="mt-1 break-all font-mono">{{ current()?.header.provenance.firmwareSha256 }}</dd></div><div><dt class="text-neutral-400">Configuration SHA-256</dt><dd class="mt-1 break-all font-mono">{{ current()?.header.provenance.configurationSha256 }}</dd></div><div><dt class="text-neutral-400">Model SHA-256</dt><dd class="mt-1 break-all font-mono">{{ current()?.header.provenance.modelSha256 }}</dd></div><div><dt class="text-neutral-400">Calibration SHA-256</dt><dd class="mt-1 break-all font-mono">{{ current()?.header.provenance.calibrationSha256 }}</dd></div></dl></details> |
| 68 | 68 | </aside> |
| 69 | 69 | </div> |
renamed throughout · 14 hidden lines
| @@ -10,10 +10,10 @@ const result = state<VolumeReport | null>(null) renamed throughout | ||
| 10 | 10 | const busy = state(false) |
| 11 | 11 | const error = state('') |
| 12 | 12 | const page = state(0) |
| 13 | const report = derived(() => resultScenario() === scenario() ? result() : null) | |
| 14 | const source = derived(() => report() ? request() : null) | |
| 15 | const pages = derived(() => Math.max(1, Math.ceil((report()?.integrationPieces.length ?? 0) / 12))) | |
| 16 | const pieces = derived(() => report()?.integrationPieces.slice(page() * 12, page() * 12 + 12) ?? []) | |
| 13 | const volumeReport = derived(() => resultScenario() === scenario() ? result() : null) | |
| 14 | const source = derived(() => volumeReport() ? request() : null) | |
| 15 | const pages = derived(() => Math.max(1, Math.ceil((volumeReport()?.integrationPieces.length ?? 0) / 12))) | |
| 16 | const pieces = derived(() => volumeReport()?.integrationPieces.slice(page() * 12, page() * 12 + 12) ?? []) | |
| 17 | 17 | const extent = derived(() => { |
| 18 | 18 | const points = source()?.boundary.outer ?? [[0, 0], [1, 1]] |
| 19 | 19 | const east = points.map(point => point[0]), north = points.map(point => point[1]) |
| @@ -94,27 +94,27 @@ function execute() { clear(); busy.set(true); computation.start() } | ||
| 94 | 94 | </div> |
| 95 | 95 | <p :if="busy()" role="status" class="mt-5 text-emerald-200 text-sm">Clipping the known surface and integrating above and below the declared base.</p> |
| 96 | 96 | <p :if="error()" role="alert" class="mt-5 p-4 break-words text-red-200 border border-red-400 rounded">{{ error() }}</p> |
| 97 | <p :if="!report() && !busy() && !error()" class="mt-7 py-5 text-neutral-400 border-neutral-700 border-t">No analysis yet. Choose an example to inspect exact surface integration and its evidence limits.</p> | |
| 98 | <div :if="report()" class="mt-9"> | |
| 97 | <p :if="!volumeReport() && !busy() && !error()" class="mt-7 py-5 text-neutral-400 border-neutral-700 border-t">No analysis yet. Choose an example to inspect exact surface integration and its evidence limits.</p> | |
| 98 | <div :if="volumeReport()" class="mt-9"> | |
| 99 | 99 | <div class="grid gap-8 lg:grid-cols-[1fr_1.3fr]"> |
| 100 | 100 | <aside class="min-w-0"> |
| 101 | 101 | <h2 class="font-semibold text-lg">Covered-domain result</h2> |
| 102 | <p class="mt-3 break-words font-mono text-emerald-200 text-sm" data-volume-status>{{ report()?.status }} / {{ report()?.evidence }}</p> | |
| 102 | <p class="mt-3 break-words font-mono text-emerald-200 text-sm" data-volume-status>{{ volumeReport()?.status }} / {{ volumeReport()?.evidence }}</p> | |
| 103 | 103 | <dl class="grid gap-5 grid-cols-2 mt-5 text-sm"> |
| 104 | <div><dt class="text-neutral-400">Above base</dt><dd class="mt-2 font-mono" data-volume-above>{{ quantity(report()?.aboveBaseM3) }}</dd></div> | |
| 105 | <div><dt class="text-neutral-400">Below base</dt><dd class="mt-2 font-mono" data-volume-below>{{ quantity(report()?.belowBaseM3) }}</dd></div> | |
| 106 | <div><dt class="text-neutral-400">Signed top minus base</dt><dd class="mt-2 font-mono" data-volume-signed>{{ quantity(report()?.signedTopMinusBaseM3) }}</dd></div> | |
| 107 | <div><dt class="text-neutral-400">Full boundary signed</dt><dd class="mt-2 font-mono" data-volume-full>{{ quantity(report()?.fullBoundarySignedM3) }}</dd></div> | |
| 104 | <div><dt class="text-neutral-400">Above base</dt><dd class="mt-2 font-mono" data-volume-above>{{ quantity(volumeReport()?.aboveBaseM3) }}</dd></div> | |
| 105 | <div><dt class="text-neutral-400">Below base</dt><dd class="mt-2 font-mono" data-volume-below>{{ quantity(volumeReport()?.belowBaseM3) }}</dd></div> | |
| 106 | <div><dt class="text-neutral-400">Signed top minus base</dt><dd class="mt-2 font-mono" data-volume-signed>{{ quantity(volumeReport()?.signedTopMinusBaseM3) }}</dd></div> | |
| 107 | <div><dt class="text-neutral-400">Full boundary signed</dt><dd class="mt-2 font-mono" data-volume-full>{{ quantity(volumeReport()?.fullBoundarySignedM3) }}</dd></div> | |
| 108 | 108 | </dl> |
| 109 | 109 | <p class="mt-4 leading-relaxed text-neutral-400 text-xs">Above and below are positive magnitudes. Signed volume equals above minus below. Partial coverage does not establish the volume of the whole boundary.</p> |
| 110 | 110 | <h3 class="mt-7 font-semibold text-sm">Boundary and domain</h3> |
| 111 | 111 | <dl class="grid gap-4 grid-cols-2 mt-4 text-sm"> |
| 112 | <div><dt class="text-neutral-400">Boundary area</dt><dd class="mt-1">{{ quantity(report()?.boundaryAreaM2, 'm²') }}</dd></div> | |
| 113 | <div><dt class="text-neutral-400">Covered area</dt><dd class="mt-1">{{ quantity(report()?.coveredAreaM2, 'm²') }}</dd></div> | |
| 114 | <div><dt class="text-neutral-400">Uncovered area</dt><dd class="mt-1" data-volume-gap>{{ quantity(report()?.uncoveredAreaM2, 'm²') }}</dd></div> | |
| 115 | <div><dt class="text-neutral-400">Coverage</dt><dd class="mt-1">{{ ((report()?.coverageFraction ?? 0) * 100).toFixed(2) }}%</dd></div> | |
| 112 | <div><dt class="text-neutral-400">Boundary area</dt><dd class="mt-1">{{ quantity(volumeReport()?.boundaryAreaM2, 'm²') }}</dd></div> | |
| 113 | <div><dt class="text-neutral-400">Covered area</dt><dd class="mt-1">{{ quantity(volumeReport()?.coveredAreaM2, 'm²') }}</dd></div> | |
| 114 | <div><dt class="text-neutral-400">Uncovered area</dt><dd class="mt-1" data-volume-gap>{{ quantity(volumeReport()?.uncoveredAreaM2, 'm²') }}</dd></div> | |
| 115 | <div><dt class="text-neutral-400">Coverage</dt><dd class="mt-1">{{ ((volumeReport()?.coverageFraction ?? 0) * 100).toFixed(2) }}%</dd></div> | |
| 116 | 116 | <div><dt class="text-neutral-400">Boundary vertices / holes</dt><dd class="mt-1">{{ source()?.boundary.outer.length }} / {{ source()?.boundary.holes.length }}</dd></div> |
| 117 | <div><dt class="text-neutral-400">Excluded nodata cells</dt><dd class="mt-1">{{ report()?.excludedGridCells.length }}</dd></div> | |
| 117 | <div><dt class="text-neutral-400">Excluded nodata cells</dt><dd class="mt-1">{{ volumeReport()?.excludedGridCells.length }}</dd></div> | |
| 118 | 118 | </dl> |
| 119 | 119 | <h3 class="mt-7 font-semibold text-sm">Explicit base plane</h3> |
| 120 | 120 | <p class="mt-3 leading-relaxed text-neutral-400 text-sm">{{ source()?.base.assumption }}</p> |
| @@ -136,20 +136,20 @@ function execute() { clear(); busy.set(true); computation.start() } | ||
| 136 | 136 | <p class="mt-3 text-neutral-400 text-xs">Colour intensity spans 0 to {{ colorScale().toFixed(3) }} m in absolute mean height difference. Exact volume integration splits every contribution at the base crossing. Holes are excluded from the domain.</p> |
| 137 | 137 | <h3 class="mt-7 font-semibold text-sm">Uncertainty and reference</h3> |
| 138 | 138 | <dl class="grid gap-5 grid-cols-2 mt-4 text-sm"> |
| 139 | <div><dt class="text-neutral-400">Conditional vertical bound</dt><dd class="mt-1 font-mono" data-volume-conditional>{{ quantity(report()?.uncertainty.conditionalVerticalBoundM3) }}</dd></div> | |
| 139 | <div><dt class="text-neutral-400">Conditional vertical bound</dt><dd class="mt-1 font-mono" data-volume-conditional>{{ quantity(volumeReport()?.uncertainty.conditionalVerticalBoundM3) }}</dd></div> | |
| 140 | 140 | <div><dt class="text-neutral-400">Overall accuracy bound</dt><dd class="mt-1 font-mono">unavailable</dd></div> |
| 141 | <div><dt class="text-neutral-400">Declared horizontal bound</dt><dd class="mt-1">{{ quantity(report()?.uncertainty.horizontalBoundM, 'm') }}</dd></div> | |
| 142 | <div><dt class="text-neutral-400">Reference comparison</dt><dd class="mt-1 break-words" data-volume-reference>{{ report()?.comparison.status }}</dd></div> | |
| 141 | <div><dt class="text-neutral-400">Declared horizontal bound</dt><dd class="mt-1">{{ quantity(volumeReport()?.uncertainty.horizontalBoundM, 'm') }}</dd></div> | |
| 142 | <div><dt class="text-neutral-400">Reference comparison</dt><dd class="mt-1 break-words" data-volume-reference>{{ volumeReport()?.comparison.status }}</dd></div> | |
| 143 | 143 | </dl> |
| 144 | 144 | <p class="mt-4 leading-relaxed text-neutral-400 text-xs">A conditional bound adds the declared top and base vertical errors over covered area. It excludes boundary, horizontal, classification and field calibration errors. Unknown inputs remain unavailable.</p> |
| 145 | 145 | <div :if="source()?.reference" class="mt-4 pl-4 border-l border-neutral-600"> |
| 146 | 146 | <p class="break-words text-sm">{{ source()?.reference?.id }} / {{ source()?.reference?.independence }}</p> |
| 147 | 147 | <p class="mt-2 break-words text-neutral-400 text-xs">{{ source()?.reference?.method }} / {{ source()?.reference?.surveyedAt }}</p> |
| 148 | 148 | <p class="mt-2 text-neutral-400 text-xs">Reference evidence: {{ source()?.reference?.evidence }}. Operator declarations are not authenticated survey validation.</p> |
| 149 | <p class="mt-3 text-sm">Computed minus reference: {{ quantity(report()?.comparison.signedDifferenceM3) }}</p> | |
| 150 | <p class="mt-2 text-neutral-400 text-xs">Relative signed difference: {{ report()?.comparison.relativeSignedDifference === null ? 'unavailable' : ((report()?.comparison.relativeSignedDifference ?? 0) * 100).toFixed(2) + '%' }}</p> | |
| 151 | <p class="mt-2 text-neutral-400 text-xs">Combined declared bound: {{ quantity(report()?.comparison.combinedDeclaredBoundM3) }}</p> | |
| 152 | <p class="mt-2 text-neutral-400 text-xs">Within combined declared bound: {{ report()?.comparison.withinCombinedDeclaredBound === null ? 'unavailable' : report()?.comparison.withinCombinedDeclaredBound ? 'yes' : 'no' }}. This comparison does not establish overall accuracy.</p> | |
| 149 | <p class="mt-3 text-sm">Computed minus reference: {{ quantity(volumeReport()?.comparison.signedDifferenceM3) }}</p> | |
| 150 | <p class="mt-2 text-neutral-400 text-xs">Relative signed difference: {{ volumeReport()?.comparison.relativeSignedDifference === null ? 'unavailable' : ((volumeReport()?.comparison.relativeSignedDifference ?? 0) * 100).toFixed(2) + '%' }}</p> | |
| 151 | <p class="mt-2 text-neutral-400 text-xs">Combined declared bound: {{ quantity(volumeReport()?.comparison.combinedDeclaredBoundM3) }}</p> | |
| 152 | <p class="mt-2 text-neutral-400 text-xs">Within combined declared bound: {{ volumeReport()?.comparison.withinCombinedDeclaredBound === null ? 'unavailable' : volumeReport()?.comparison.withinCombinedDeclaredBound ? 'yes' : 'no' }}. This comparison does not establish overall accuracy.</p> | |
| 153 | 153 | </div> |
| 154 | 154 | </div> |
| 155 | 155 | </div> |
| @@ -158,9 +158,9 @@ function execute() { clear(); busy.set(true); computation.start() } | ||
| 158 | 158 | <dl class="grid gap-5 sm:grid-cols-2 mt-5 text-sm"> |
| 159 | 159 | <div class="min-w-0"><dt class="text-neutral-400">Source identity / geometry</dt><dd class="mt-1 break-words">{{ source()?.surface.metadata.id }} / {{ source()?.surface.geometry.kind }}</dd></div> |
| 160 | 160 | <div class="min-w-0"><dt class="text-neutral-400">Evidence / source kind</dt><dd class="mt-1 break-words">{{ source()?.surface.metadata.provenance.evidence }} / {{ source()?.surface.metadata.provenance.sourceKind }}</dd></div> |
| 161 | <div class="min-w-0"><dt class="text-neutral-400">Horizontal reference</dt><dd class="mt-1 break-words">{{ report()?.reference.horizontalCrs.id }} / {{ report()?.reference.horizontalCrs.definition }}</dd></div> | |
| 162 | <div class="min-w-0"><dt class="text-neutral-400">Vertical datum</dt><dd class="mt-1 break-words">{{ report()?.reference.verticalDatum.id }} / {{ report()?.reference.verticalDatum.definition }}</dd></div> | |
| 163 | <div><dt class="text-neutral-400">Coordinate epoch</dt><dd class="mt-1">{{ report()?.reference.horizontalCrs.coordinateEpoch ?? 'not declared' }}</dd></div> | |
| 161 | <div class="min-w-0"><dt class="text-neutral-400">Horizontal reference</dt><dd class="mt-1 break-words">{{ volumeReport()?.reference.horizontalCrs.id }} / {{ volumeReport()?.reference.horizontalCrs.definition }}</dd></div> | |
| 162 | <div class="min-w-0"><dt class="text-neutral-400">Vertical datum</dt><dd class="mt-1 break-words">{{ volumeReport()?.reference.verticalDatum.id }} / {{ volumeReport()?.reference.verticalDatum.definition }}</dd></div> | |
| 163 | <div><dt class="text-neutral-400">Coordinate epoch</dt><dd class="mt-1">{{ volumeReport()?.reference.horizontalCrs.coordinateEpoch ?? 'not declared' }}</dd></div> | |
| 164 | 164 | <div><dt class="text-neutral-400">Axes and units</dt><dd class="mt-1">Easting / northing / elevation up, metres</dd></div> |
| 165 | 165 | <div><dt class="text-neutral-400">Acquisition / meshing status</dt><dd class="mt-1">{{ source()?.surface.metadata.provenance.acquisition.status }} / {{ source()?.surface.metadata.provenance.reconstructionOrMeshing.status }}</dd></div> |
| 166 | 166 | <div><dt class="text-neutral-400">Boresight / time calibration</dt><dd class="mt-1">{{ source()?.surface.metadata.provenance.boresightCalibration.status }} / {{ source()?.surface.metadata.provenance.timeCalibration.status }}</dd></div> |
| @@ -175,10 +175,10 @@ function execute() { clear(); busy.set(true); computation.start() } | ||
| 175 | 175 | <div class="overflow-x-auto mt-5 max-w-full border border-neutral-700 rounded" tabindex="0" role="region" aria-label="Scrollable contribution audit table"> |
| 176 | 176 | <table class="w-full text-left text-xs whitespace-nowrap"><caption class="sr-only">Clipped surface contributions, unweighted area and volumes, with signed boundary weights</caption><thead class="text-neutral-300 bg-neutral-900"><tr><th scope="col" class="p-3">Source / part</th><th scope="col" class="p-3">Weight</th><th scope="col" class="p-3">Area m²</th><th scope="col" class="p-3">Above m³</th><th scope="col" class="p-3">Below m³</th><th scope="col" class="p-3">Signed m³</th></tr></thead><tbody><tr :for="piece in pieces()" class="border-neutral-800 border-t"><th scope="row" class="p-3 font-normal"><span class="block">{{ piece.sourceId }}</span><span class="block mt-1 text-neutral-400">{{ piece.boundaryPart }}</span></th><td class="p-3 font-mono">{{ piece.weight }}</td><td class="p-3 font-mono">{{ piece.areaM2.toFixed(3) }}</td><td class="p-3 font-mono">{{ piece.aboveBaseM3.toFixed(3) }}</td><td class="p-3 font-mono">{{ piece.belowBaseM3.toFixed(3) }}</td><td class="p-3 font-mono">{{ piece.signedTopMinusBaseM3.toFixed(3) }}</td></tr></tbody></table> |
| 177 | 177 | </div> |
| 178 | <p :if="!report()?.integrationPieces.length" class="mt-4 text-neutral-400 text-sm">No covered surface contributions intersect this boundary.</p> | |
| 179 | <div class="flex flex-wrap gap-3 items-center mt-4 text-sm"><button type="button" @click="page.update(value => Math.max(0, value - 1))" :disabled="page() === 0" class="px-3 py-2 border border-neutral-600 rounded focus-visible:outline-emerald-200 disabled:opacity-40">Previous pieces</button><span role="status">Page {{ page() + 1 }} / {{ pages() }}. {{ report()?.integrationPieces.length }} pieces.</span><button type="button" @click="page.update(value => Math.min(pages() - 1, value + 1))" :disabled="page() >= pages() - 1" class="px-3 py-2 border border-neutral-600 rounded focus-visible:outline-emerald-200 disabled:opacity-40">Next pieces</button></div> | |
| 178 | <p :if="!volumeReport()?.integrationPieces.length" class="mt-4 text-neutral-400 text-sm">No covered surface contributions intersect this boundary.</p> | |
| 179 | <div class="flex flex-wrap gap-3 items-center mt-4 text-sm"><button type="button" @click="page.update(value => Math.max(0, value - 1))" :disabled="page() === 0" class="px-3 py-2 border border-neutral-600 rounded focus-visible:outline-emerald-200 disabled:opacity-40">Previous pieces</button><span role="status">Page {{ page() + 1 }} / {{ pages() }}. {{ volumeReport()?.integrationPieces.length }} pieces.</span><button type="button" @click="page.update(value => Math.min(pages() - 1, value + 1))" :disabled="page() >= pages() - 1" class="px-3 py-2 border border-neutral-600 rounded focus-visible:outline-emerald-200 disabled:opacity-40">Next pieces</button></div> | |
| 180 | 180 | </section> |
| 181 | <details class="mt-8 pt-5 border-neutral-700 border-t"><summary class="text-sm focus-visible:outline-emerald-200 cursor-pointer">Warnings, uncertainty assumptions and evidence limits</summary><ul class="mt-4 space-y-3 text-neutral-400 text-sm"><li :for="warning in report()?.warnings ?? []" class="break-words text-amber-200">{{ warning }}</li><li :for="assumption in report()?.uncertainty.assumptions ?? []" class="break-words">{{ assumption }}</li><li :for="limitation in report()?.limitations ?? []" class="break-words">{{ limitation }}</li></ul></details> | |
| 181 | <details class="mt-8 pt-5 border-neutral-700 border-t"><summary class="text-sm focus-visible:outline-emerald-200 cursor-pointer">Warnings, uncertainty assumptions and evidence limits</summary><ul class="mt-4 space-y-3 text-neutral-400 text-sm"><li :for="warning in volumeReport()?.warnings ?? []" class="break-words text-amber-200">{{ warning }}</li><li :for="assumption in volumeReport()?.uncertainty.assumptions ?? []" class="break-words">{{ assumption }}</li><li :for="limitation in volumeReport()?.limitations ?? []" class="break-words">{{ limitation }}</li></ul></details> | |
| 182 | 182 | <p class="mt-6 text-neutral-400 text-xs">For known-surface import and auditable dataset export, use the native Stacks volume commands. Binary LiDAR, raw-point meshing and surveyed field validation remain separate work.</p> |
| 183 | 183 | <a href="https://github.com/stacksjs/drone/issues/19" class="inline-block mt-4 text-emerald-200 text-sm underline underline-offset-4">Volume processing scope and remaining evidence</a> |
| 184 | 184 | </div> |
| @@ -26,6 +26,7 @@ export * from './volumes/engine' | ||
| 26 | 26 | export * from './volumes/fixtures' |
| 27 | 27 | export * from './volumes/import' |
| 28 | 28 | export * from './volumes/geotiff' |
| 29 | export * from './volumes/las' | |
| 29 | 30 | export * from './inspection/types' |
| 30 | 31 | export * from './inspection/planner' |
| 31 | 32 | export * from './inspection/geometry' |
| @@ -0,0 +1,46 @@ | ||
| 1 | export class SurfaceByteRejection extends Error { | |
| 2 | constructor(readonly kind: 'input' | 'budget' | 'unavailable') { super(kind) } | |
| 3 | } | |
| 4 | function reject(kind: 'input' | 'budget' | 'unavailable' = 'input'): never { throw new SurfaceByteRejection(kind) } | |
| 5 | ||
| 6 | // Only trusted native functions are captured. Caller properties and prototypes are never read. | |
| 7 | const nativeBytes = (() => { | |
| 8 | try { | |
| 9 | const prototype = Object.getPrototypeOf(Uint8Array.prototype) | |
| 10 | const length = Object.getOwnPropertyDescriptor(prototype, 'length')?.get | |
| 11 | const byteLength = Object.getOwnPropertyDescriptor(prototype, 'byteLength')?.get | |
| 12 | const byteOffset = Object.getOwnPropertyDescriptor(prototype, 'byteOffset')?.get | |
| 13 | const buffer = Object.getOwnPropertyDescriptor(prototype, 'buffer')?.get | |
| 14 | const tag = Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag)?.get | |
| 15 | const set = Object.getOwnPropertyDescriptor(prototype, 'set')?.value | |
| 16 | const backingLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get | |
| 17 | const resizable = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'resizable')?.get | |
| 18 | if (!length || !byteLength || !byteOffset || !buffer || !tag || typeof set !== 'function' || !backingLength || !resizable) return null | |
| 19 | return { length, byteLength, byteOffset, buffer, tag, set, backingLength, resizable } | |
| 20 | } | |
| 21 | catch { return null } | |
| 22 | })() | |
| 23 | ||
| 24 | export function captureSurfaceBytes(input: unknown): Uint8Array<ArrayBuffer> { | |
| 25 | if (!nativeBytes) return reject('unavailable') | |
| 26 | let length: unknown, byteLength: unknown, offset: unknown, backing: unknown, backingLength: unknown, resizable: unknown | |
| 27 | try { | |
| 28 | if (Reflect.apply(nativeBytes.tag, input, []) !== 'Uint8Array') return reject() | |
| 29 | length = Reflect.apply(nativeBytes.length, input, []) | |
| 30 | byteLength = Reflect.apply(nativeBytes.byteLength, input, []) | |
| 31 | offset = Reflect.apply(nativeBytes.byteOffset, input, []) | |
| 32 | backing = Reflect.apply(nativeBytes.buffer, input, []) | |
| 33 | // These getters reject SharedArrayBuffer and other non-ArrayBuffer backing. | |
| 34 | backingLength = Reflect.apply(nativeBytes.backingLength, backing, []) | |
| 35 | resizable = Reflect.apply(nativeBytes.resizable, backing, []) | |
| 36 | } | |
| 37 | catch (error) { | |
| 38 | if (error instanceof TypeError || error instanceof SurfaceByteRejection) return reject() | |
| 39 | throw error | |
| 40 | } | |
| 41 | if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 8 || byteLength !== length || typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || typeof backingLength !== 'number' || !Number.isSafeInteger(backingLength) || backingLength < length || offset > backingLength - length || resizable !== false) return reject() | |
| 42 | if (length > 8388608) return reject('budget') | |
| 43 | const owned = new Uint8Array(length) | |
| 44 | Reflect.apply(nativeBytes.set, owned, [input, 0]) | |
| 45 | return owned | |
| 46 | } | |
| @@ -4,13 +4,30 @@ import { join } from 'node:path' | ||
| 4 | 4 | import { writeCompletedArtifact } from '../../artifacts/completed-directory' |
| 5 | 5 | import { processStockpile, validateVolumeRequest } from './engine' |
| 6 | 6 | import { exportGridSurface, exportPlySurface } from './import' |
| 7 | import { importLasSurface } from './las' | |
| 8 | import { captureSurfaceBytes } from './bytes' | |
| 9 | ||
| 10 | export interface LasVolumeImportSource { format: 'las', bytes: Uint8Array, declarationJson: string } | |
| 11 | function captureLasSource(input: LasVolumeImportSource) { | |
| 12 | try { | |
| 13 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== 3) throw new TypeError() | |
| 14 | const values: unknown[] = ['format', 'bytes', 'declarationJson'].map(key => { | |
| 15 | const d = Object.getOwnPropertyDescriptor(input, key) | |
| 16 | if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError() | |
| 17 | return d.value | |
| 18 | }) | |
| 19 | if (values[0] !== 'las' || typeof values[2] !== 'string' || values[2].length > 32768) throw new TypeError() | |
| 20 | return { bytes: captureSurfaceBytes(values[1]), declarationJson: values[2] } | |
| 21 | } | |
| 22 | catch { throw new TypeError('las-dataset-source-input') } | |
| 23 | } | |
| 7 | 24 | |
| 8 | 25 | function json(value: unknown): string { |
| 9 | 26 | return `${JSON.stringify(value, null, 2)}\n` |
| 10 | 27 | } |
| 11 | 28 | |
| 12 | function digest(contents: string): string { | |
| 13 | return createHash('sha256').update(contents, 'utf8').digest('hex') | |
| 29 | function digest(contents: string | Uint8Array): string { | |
| 30 | return createHash('sha256').update(contents).digest('hex') | |
| 14 | 31 | } |
| 15 | 32 | |
| 16 | 33 | function csvCell(value: string | number): string { |
| @@ -19,8 +36,11 @@ function csvCell(value: string | number): string { | ||
| 19 | 36 | } |
| 20 | 37 | |
| 21 | 38 | /** Recompute and publish an exclusive, completion-marked numerical dataset. Server only. */ |
| 22 | export async function writeVolumeDataset(directory: string, input: VolumeRequest): Promise<{ directory: string, manifest: string, report: string, contributions: string, manifestSha256: string }> { | |
| 39 | export async function writeVolumeDataset(directory: string, input: VolumeRequest, importSource?: LasVolumeImportSource): Promise<{ directory: string, manifest: string, report: string, contributions: string, manifestSha256: string }> { | |
| 23 | 40 | const request = validateVolumeRequest(input) |
| 41 | const capturedSource = importSource === undefined ? null : captureLasSource(importSource) | |
| 42 | const proof = capturedSource ? await importLasSurface(capturedSource.bytes, capturedSource.declarationJson) : null | |
| 43 | if (proof && JSON.stringify(proof.surface) !== JSON.stringify(request.surface)) throw new Error('las-dataset-source-mismatch') | |
| 24 | 44 | const report = processStockpile(request) |
| 25 | 45 | const sourcePath = request.surface.geometry.kind === 'cell-grid' ? 'surface.asc' : 'surface.ply' |
| 26 | 46 | const sourceText = request.surface.geometry.kind === 'cell-grid' ? exportGridSurface(request.surface) : exportPlySurface(request.surface) |
| @@ -29,13 +49,18 @@ export async function writeVolumeDataset(directory: string, input: VolumeRequest | ||
| 29 | 49 | sidecar.provenance.sourceSha256 = regeneratedSourceSha256 |
| 30 | 50 | const columns = ['sourceId', 'boundaryPart', 'weight', 'areaM2', 'aboveBaseM3', 'belowBaseM3', 'signedTopMinusBaseM3', 'polygonEastNorthM'] |
| 31 | 51 | const contributions = `${[columns.join(','), ...report.integrationPieces.map(piece => [piece.sourceId, piece.boundaryPart, piece.weight, piece.areaM2, piece.aboveBaseM3, piece.belowBaseM3, piece.signedTopMinusBaseM3, JSON.stringify(piece.polygonM)].map(csvCell).join(','))].join('\n')}\n` |
| 32 | const entries = [ | |
| 52 | const entries: { path: string, contents: string | Uint8Array }[] = [ | |
| 33 | 53 | { path: 'request.json', contents: json(request) }, |
| 34 | 54 | { path: 'report.json', contents: json(report) }, |
| 35 | 55 | { path: 'contributions.csv', contents: contributions }, |
| 36 | 56 | { path: sourcePath, contents: sourceText }, |
| 37 | 57 | { path: 'surface.metadata.json', contents: json(sidecar) }, |
| 38 | 58 | ] |
| 59 | if (capturedSource && proof) entries.push( | |
| 60 | { path: 'source.las', contents: capturedSource.bytes }, | |
| 61 | { path: 'source.declaration.json', contents: capturedSource.declarationJson }, | |
| 62 | { path: 'source.gridding.json', contents: json(proof.processing) }, | |
| 63 | ) | |
| 39 | 64 | const manifest = { |
| 40 | 65 | version: 1, |
| 41 | 66 | kind: 'stockpile-volume-dataset', |
| @@ -51,7 +76,8 @@ export async function writeVolumeDataset(directory: string, input: VolumeRequest | ||
| 51 | 76 | regeneratedSourceSha256, |
| 52 | 77 | sourcePath, |
| 53 | 78 | metadataPath: 'surface.metadata.json', |
| 54 | files: entries.map(entry => ({ path: entry.path, bytes: new TextEncoder().encode(entry.contents).byteLength, sha256: digest(entry.contents) })), | |
| 79 | ...(proof ? { sourceProcessing: { format: 'las', originalSourcePath: 'source.las', declarationPath: 'source.declaration.json', processingPath: 'source.gridding.json', method: proof.processing.method, representationErrorBoundM: null, classificationVerified: false } } : {}), | |
| 80 | files: entries.map(entry => ({ path: entry.path, bytes: typeof entry.contents === 'string' ? new TextEncoder().encode(entry.contents).byteLength : entry.contents.byteLength, sha256: digest(entry.contents) })), | |
| 55 | 81 | policy: { |
| 56 | 82 | coordinates: 'Easting, northing, elevation up in declared metre-based reference. No CRS or vertical datum conversion.', |
| 57 | 83 | surfaceRepresentation: request.surface.geometry.kind === 'cell-grid' ? 'Piecewise-constant cell elevations; corner-origin, north-first AAIGrid; nodata remains missing.' : 'Indexed single-valued triangular terrain surface; ASCII PLY; no raw-point triangulation.', |
| @@ -2,7 +2,7 @@ import { constants } from 'node:fs' | ||
| 2 | 2 | import { open } from 'node:fs/promises' |
| 3 | 3 | |
| 4 | 4 | /** Bounded local bytes. Parent paths remain caller-owned; concurrent writes are not atomic. */ |
| 5 | export async function readGeoTiffFile(path: string): Promise<Uint8Array> { | |
| 5 | async function readBinarySurfaceFile(path: string): Promise<Uint8Array> { | |
| 6 | 6 | try { |
| 7 | 7 | if (typeof path !== 'string' || !Number.isSafeInteger(constants.O_NOFOLLOW) || constants.O_NOFOLLOW <= 0 || !Number.isSafeInteger(constants.O_NONBLOCK) || constants.O_NONBLOCK <= 0) throw new Error() |
| 8 | 8 | const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) |
| @@ -21,5 +21,15 @@ export async function readGeoTiffFile(path: string): Promise<Uint8Array> { | ||
| 21 | 21 | } |
| 22 | 22 | finally { await file.close() } |
| 23 | 23 | } |
| 24 | catch { throw new Error('binary-surface-file-unavailable') } | |
| 25 | } | |
| 26 | ||
| 27 | export async function readGeoTiffFile(path: string): Promise<Uint8Array> { | |
| 28 | try { return await readBinarySurfaceFile(path) } | |
| 24 | 29 | catch { throw new Error('geotiff-file-unavailable') } |
| 25 | 30 | } |
| 31 | ||
| 32 | export async function readLasFile(path: string): Promise<Uint8Array> { | |
| 33 | try { return await readBinarySurfaceFile(path) } | |
| 34 | catch { throw new Error('las-file-unavailable') } | |
| 35 | } | |
| @@ -1,3 +1,4 @@ | ||
| 1 | import { captureSurfaceBytes, SurfaceByteRejection } from './bytes' | |
| 1 | 2 | import type { SurfaceGeometry, SurfaceMetadata } from './types' |
| 2 | 3 | import { validateKnownSurface, validateSurfaceMetadata } from './validation' |
| 3 | 4 | |
| @@ -20,48 +21,6 @@ class Rejection extends Error { | ||
| 20 | 21 | } |
| 21 | 22 | function reject(kind: FailureKind = 'input'): never { throw new Rejection(kind) } |
| 22 | 23 | |
| 23 | // Only trusted native functions are captured. Caller properties and prototypes are never read. | |
| 24 | const nativeBytes = (() => { | |
| 25 | try { | |
| 26 | const prototype = Object.getPrototypeOf(Uint8Array.prototype) | |
| 27 | const length = Object.getOwnPropertyDescriptor(prototype, 'length')?.get | |
| 28 | const byteLength = Object.getOwnPropertyDescriptor(prototype, 'byteLength')?.get | |
| 29 | const byteOffset = Object.getOwnPropertyDescriptor(prototype, 'byteOffset')?.get | |
| 30 | const buffer = Object.getOwnPropertyDescriptor(prototype, 'buffer')?.get | |
| 31 | const tag = Object.getOwnPropertyDescriptor(prototype, Symbol.toStringTag)?.get | |
| 32 | const set = Object.getOwnPropertyDescriptor(prototype, 'set')?.value | |
| 33 | const backingLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get | |
| 34 | const resizable = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'resizable')?.get | |
| 35 | if (!length || !byteLength || !byteOffset || !buffer || !tag || typeof set !== 'function' || !backingLength || !resizable) return null | |
| 36 | return { length, byteLength, byteOffset, buffer, tag, set, backingLength, resizable } | |
| 37 | } | |
| 38 | catch { return null } | |
| 39 | })() | |
| 40 | ||
| 41 | function snapshot(input: Uint8Array): Uint8Array<ArrayBuffer> { | |
| 42 | if (!nativeBytes) return reject('unavailable') | |
| 43 | let length: unknown, byteLength: unknown, offset: unknown, backing: unknown, backingLength: unknown, resizable: unknown | |
| 44 | try { | |
| 45 | if (Reflect.apply(nativeBytes.tag, input, []) !== 'Uint8Array') return reject() | |
| 46 | length = Reflect.apply(nativeBytes.length, input, []) | |
| 47 | byteLength = Reflect.apply(nativeBytes.byteLength, input, []) | |
| 48 | offset = Reflect.apply(nativeBytes.byteOffset, input, []) | |
| 49 | backing = Reflect.apply(nativeBytes.buffer, input, []) | |
| 50 | // These getters reject SharedArrayBuffer and other non-ArrayBuffer backing. | |
| 51 | backingLength = Reflect.apply(nativeBytes.backingLength, backing, []) | |
| 52 | resizable = Reflect.apply(nativeBytes.resizable, backing, []) | |
| 53 | } | |
| 54 | catch (error) { | |
| 55 | if (error instanceof TypeError || error instanceof Rejection) return reject() | |
| 56 | throw error | |
| 57 | } | |
| 58 | if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 8 || byteLength !== length || typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || typeof backingLength !== 'number' || !Number.isSafeInteger(backingLength) || backingLength < length || offset > backingLength - length || resizable !== false) return reject() | |
| 59 | if (length > 8388608) return reject('budget') | |
| 60 | const owned = new Uint8Array(length) | |
| 61 | Reflect.apply(nativeBytes.set, owned, [input, 0]) | |
| 62 | return owned | |
| 63 | } | |
| 64 | ||
| 65 | 24 | // Called only on JSON.parse-owned data, never an arbitrary caller object graph. |
| 66 | 25 | function closed(value: unknown, keys: readonly string[]): Record<string, unknown> { |
| 67 | 26 | if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) return reject() |
| @@ -273,7 +232,7 @@ function raster(owned: Uint8Array<ArrayBuffer>, metadata: SurfaceMetadata): Rast | ||
| 273 | 232 | /** Restrictive numeric elevation profile, without acquisition or accuracy authentication. */ |
| 274 | 233 | export async function importGeoTiffSurface(bytes: Uint8Array, declarationJson: string): Promise<GeoTiffCellSurface> { |
| 275 | 234 | try { |
| 276 | const owned = snapshot(bytes), metadata = declaration(declarationJson), grid = raster(owned, metadata) | |
| 235 | const owned = captureSurfaceBytes(bytes), metadata = declaration(declarationJson), grid = raster(owned, metadata) | |
| 277 | 236 | const subtle = globalThis.crypto?.subtle |
| 278 | 237 | if (!subtle || typeof subtle.digest !== 'function') return reject('unavailable') |
| 279 | 238 | const digest = new Uint8Array(await subtle.digest('SHA-256', owned)) |
| @@ -295,7 +254,7 @@ export async function importGeoTiffSurface(bytes: Uint8Array, declarationJson: s | ||
| 295 | 254 | return { version: 1, metadata: surface.metadata, geometry: surface.geometry } |
| 296 | 255 | } |
| 297 | 256 | catch (error) { |
| 298 | if (error instanceof Rejection) { | |
| 257 | if (error instanceof Rejection || error instanceof SurfaceByteRejection) { | |
| 299 | 258 | if (error.kind === 'input') throw new TypeError('geotiff-surface-input') |
| 300 | 259 | if (error.kind === 'budget') throw new RangeError('geotiff-surface-budget') |
| 301 | 260 | if (error.kind === 'digest') throw new Error('geotiff-source-sha256-mismatch') |
| @@ -0,0 +1,139 @@ | ||
| 1 | import type { SurfaceMetadata, SurfaceGeometry } from './types' | |
| 2 | import { captureSurfaceBytes, SurfaceByteRejection } from './bytes' | |
| 3 | import { validateKnownSurface, validateSurfaceMetadata, volumeLimits } from './validation' | |
| 4 | ||
| 5 | export interface LasGridDeclaration { | |
| 6 | version: 1 | |
| 7 | profile: 'las12-utm-mean-grid-v1' | |
| 8 | verticalReference: 'declared-sidecar' | |
| 9 | metadata: SurfaceMetadata | |
| 10 | selection: { classes: number[], returns: 'all' | 'first' | 'last', synthetic: 'exclude' | 'include' } | |
| 11 | grid: { westM: number, southM: number, width: number, height: number, cellSizeM: number, minimumPoints: number, maximumSpreadM: number } | |
| 12 | } | |
| 13 | // LAS profile admission only: these EPSG vertical CRSs have metre/up axes. | |
| 14 | // PROJ 5fa31c29f0a806568ed7bc76c1a557604db4a804, vertical_crs.sql + axis.sql. | |
| 15 | const metreUpVerticalCrs = new Set([3855, 5701, 5703, 5714, 5773, 5783]) | |
| 16 | ||
| 17 | class Rejection extends Error { | |
| 18 | constructor(readonly kind: 'input' | 'budget' | 'digest' | 'unavailable' | 'empty' = 'input') { super(kind) } | |
| 19 | } | |
| 20 | function reject(kind: Rejection['kind'] = 'input'): never { throw new Rejection(kind) } | |
| 21 | const finite = (value: unknown, min: number, max: number): value is number => typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max | |
| 22 | const integer = (value: unknown, min: number, max: number): value is number => Number.isSafeInteger(value) && finite(value, min, max) | |
| 23 | function closed(input: unknown, keys: string[]): Record<string, unknown> { | |
| 24 | if (!input || typeof input !== 'object' || Array.isArray(input) || Object.getPrototypeOf(input) !== Object.prototype || Object.keys(input).length !== keys.length || keys.some(key => !Object.hasOwn(input, key))) return reject() | |
| 25 | return input as Record<string, unknown> | |
| 26 | } | |
| 27 | function declaration(text: string): LasGridDeclaration { | |
| 28 | if (typeof text !== 'string') return reject() | |
| 29 | if (text.length > 32768 || new TextEncoder().encode(text).length > 32768) return reject('budget') | |
| 30 | const d = closed(JSON.parse(text), ['version', 'profile', 'verticalReference', 'metadata', 'selection', 'grid']) | |
| 31 | if (d.version !== 1 || d.profile !== 'las12-utm-mean-grid-v1' || d.verticalReference !== 'declared-sidecar') return reject() | |
| 32 | let metadata: SurfaceMetadata | |
| 33 | try { metadata = validateSurfaceMetadata(d.metadata) } | |
| 34 | catch { return reject() } | |
| 35 | const selection = closed(d.selection, ['classes', 'returns', 'synthetic']), grid = closed(d.grid, ['westM', 'southM', 'width', 'height', 'cellSizeM', 'minimumPoints', 'maximumSpreadM']) | |
| 36 | if (metadata.reference.horizontalCrs.kind !== 'wgs84-utm' || metadata.provenance.sourceKind !== 'lidar' && metadata.provenance.sourceKind !== 'analytic') return reject() | |
| 37 | if (!Array.isArray(selection.classes) || selection.classes.length < 1 || selection.classes.length > 32 || selection.classes.some((n, i, values) => !integer(n, 0, 31) || i > 0 && n <= values[i - 1]) | |
| 38 | || !['all', 'first', 'last'].includes(selection.returns as string) || !['exclude', 'include'].includes(selection.synthetic as string)) return reject() | |
| 39 | if (!finite(grid.westM, -1e8, 1e8) || !finite(grid.southM, -1e8, 1e8) || !integer(grid.width, 1, volumeLimits.gridCells) || !integer(grid.height, 1, volumeLimits.gridCells) | |
| 40 | || grid.width * grid.height > volumeLimits.gridCells || !finite(grid.cellSizeM, 1e-5, 1e6) || !integer(grid.minimumPoints, 1, 200000) || !finite(grid.maximumSpreadM, 0, 1e8) | |
| 41 | || !finite(grid.westM + grid.width * grid.cellSizeM, -1e8, 1e8) || !finite(grid.southM + grid.height * grid.cellSizeM, -1e8, 1e8)) return reject() | |
| 42 | return { version: 1, profile: 'las12-utm-mean-grid-v1', verticalReference: 'declared-sidecar', metadata, selection: selection as unknown as LasGridDeclaration['selection'], grid: grid as unknown as LasGridDeclaration['grid'] } | |
| 43 | } | |
| 44 | async function digest(bytes: Uint8Array<ArrayBuffer>): Promise<string> { | |
| 45 | const result = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)) | |
| 46 | return Array.from(result, n => n.toString(16).padStart(2, '0')).join('') | |
| 47 | } | |
| 48 | function projection(view: DataView, d: LasGridDeclaration): { end: number, verticalCrs: number | null } { | |
| 49 | // This explicit profile permits one inline GeoKey directory and no hidden | |
| 50 | // transform, custom CRS, waveform, extra-byte or compressed-payload metadata. | |
| 51 | if (view.getUint16(94, true) !== 227 || view.getUint32(100, true) !== 1) return reject() | |
| 52 | const start = 227, size = view.getUint16(start + 20, true), end = start + 54 + size | |
| 53 | if (end > view.byteLength || size < 32 || view.getUint16(start, true) !== 0 || view.getUint16(start + 18, true) !== 34735) return reject() | |
| 54 | const id = 'LASF_Projection' | |
| 55 | for (let i = 0; i < 16; i++) if (view.getUint8(start + 2 + i) !== (i < id.length ? id.charCodeAt(i) : 0)) return reject() | |
| 56 | const at = start + 54, count = view.getUint16(at + 6, true) | |
| 57 | if (count < 3 || count > 8 || size !== 8 + count * 8 || view.getUint16(at, true) !== 1 || view.getUint16(at + 2, true) !== 1 || ![0, 1].includes(view.getUint16(at + 4, true))) return reject() | |
| 58 | const values = new Map<number, number>() | |
| 59 | for (let i = 0; i < count; i++) { | |
| 60 | const here = at + 8 + 8 * i, key = view.getUint16(here, true), value = view.getUint16(here + 6, true) | |
| 61 | if (values.has(key) || view.getUint16(here + 2, true) !== 0 || view.getUint16(here + 4, true) !== 1 || ![1024, 1025, 2048, 2054, 3072, 3076, 4096, 4099].includes(key)) return reject() | |
| 62 | values.set(key, value) | |
| 63 | } | |
| 64 | if (values.get(1024) !== 1 || values.get(3072) !== Number(d.metadata.reference.horizontalCrs.id.slice(5)) || values.get(3076) !== 9001) return reject() | |
| 65 | for (const [key, expected] of [[1025, 2], [2048, 4326], [2054, 9102], [4099, 9001]]) if (values.has(key!) && values.get(key!) !== expected) return reject() | |
| 66 | const verticalCrs = values.get(4096) ?? null | |
| 67 | if (verticalCrs !== null && (!metreUpVerticalCrs.has(verticalCrs) || d.metadata.reference.verticalDatum.id !== 'EPSG:' + verticalCrs)) return reject() | |
| 68 | return { end, verticalCrs } | |
| 69 | } | |
| 70 | ||
| 71 | /** Original point bytes to an explicitly declared cell-mean representation. No survey acquisition or implicit meshing. */ | |
| 72 | export async function importLasSurface(input: Uint8Array, declarationJson: string) { | |
| 73 | try { | |
| 74 | const bytes = captureSurfaceBytes(input), d = declaration(declarationJson), v = new DataView(bytes.buffer) | |
| 75 | if (bytes.length < 313 || v.getUint32(0, false) !== 0x4c415346 || v.getUint8(24) !== 1 || v.getUint8(25) !== 2 || v.getUint16(6, true) > 1) return reject() | |
| 76 | const format = v.getUint8(104), recordBytes = [20, 28, 26, 34][format], count = v.getUint32(107, true), offset = v.getUint32(96, true) | |
| 77 | if (recordBytes === undefined || v.getUint16(105, true) !== recordBytes || !count) return reject() | |
| 78 | if (count > 200000) return reject('budget') | |
| 79 | const { end, verticalCrs } = projection(v, d) | |
| 80 | if (offset < end || offset + count * recordBytes !== bytes.length) return reject() | |
| 81 | for (let i = end; i < offset; i++) if (bytes[i] !== 0) return reject() | |
| 82 | const scales = [0, 1, 2].map(i => v.getFloat64(131 + 8 * i, true)), offsets = [0, 1, 2].map(i => v.getFloat64(155 + 8 * i, true)) | |
| 83 | if (scales.some(n => !finite(n, Number.MIN_VALUE, 1e6)) || offsets.some(n => !finite(n, -1e8, 1e8))) return reject() | |
| 84 | const sourceSha256 = await digest(bytes), declarationSha256 = await digest(new TextEncoder().encode(declarationJson)) | |
| 85 | if (sourceSha256 !== d.metadata.provenance.sourceSha256) return reject('digest') | |
| 86 | const grid = d.grid, cells = grid.width * grid.height, counts = new Uint32Array(cells), sums = new Float64Array(cells), corrections = new Float64Array(cells), mins = new Float64Array(cells).fill(Infinity), maxs = new Float64Array(cells).fill(-Infinity) | |
| 87 | const sourceMins = [Infinity, Infinity, Infinity], sourceMaxs = [-Infinity, -Infinity, -Infinity], byReturn = [0, 0, 0, 0, 0], byClass = new Array<number>(32).fill(0) | |
| 88 | const excluded = { withheld: 0, synthetic: 0, classification: 0, returns: 0, outsideGrid: 0 } | |
| 89 | const hasGps = format === 1 || format === 3, gpsType = hasGps ? v.getUint16(6, true) ? 'adjusted-standard-seconds' : 'week-seconds' : 'absent' | |
| 90 | let selected = 0, gpsMin = Infinity, gpsMax = -Infinity | |
| 91 | for (let i = 0; i < count; i++) { | |
| 92 | const at = offset + i * recordBytes, xyz = [0, 1, 2].map(axis => v.getInt32(at + axis * 4, true) * scales[axis]! + offsets[axis]!) | |
| 93 | if (xyz.some(n => !finite(n, -1e8, 1e8))) return reject() | |
| 94 | for (let axis = 0; axis < 3; axis++) { sourceMins[axis] = Math.min(sourceMins[axis]!, xyz[axis]!); sourceMaxs[axis] = Math.max(sourceMaxs[axis]!, xyz[axis]!) } | |
| 95 | const returns = v.getUint8(at + 14), returned = returns & 7, totalReturns = (returns >> 3) & 7, classification = v.getUint8(at + 15), classId = classification & 31 | |
| 96 | if (returned < 1 || returned > totalReturns || totalReturns > 5 || Math.abs(v.getInt8(at + 16)) > 90) return reject() | |
| 97 | byReturn[returned - 1] = byReturn[returned - 1]! + 1; byClass[classId] = byClass[classId]! + 1 | |
| 98 | if (hasGps) { | |
| 99 | const time = v.getFloat64(at + 20, true) | |
| 100 | if (!finite(time, gpsType === 'week-seconds' ? 0 : -1e9, gpsType === 'week-seconds' ? 604800 : 1e10) || gpsType === 'week-seconds' && time === 604800) return reject() | |
| 101 | gpsMin = Math.min(gpsMin, time); gpsMax = Math.max(gpsMax, time) | |
| 102 | } | |
| 103 | if (classification & 128) { excluded.withheld++; continue } | |
| 104 | if (classification & 32 && d.selection.synthetic === 'exclude') { excluded.synthetic++; continue } | |
| 105 | if (!d.selection.classes.includes(classId)) { excluded.classification++; continue } | |
| 106 | if (d.selection.returns === 'first' && returned !== 1 || d.selection.returns === 'last' && returned !== totalReturns) { excluded.returns++; continue } | |
| 107 | const column = Math.floor((xyz[0]! - grid.westM) / grid.cellSizeM), southRow = Math.floor((xyz[1]! - grid.southM) / grid.cellSizeM) | |
| 108 | if (column < 0 || southRow < 0 || column >= grid.width || southRow >= grid.height) { excluded.outsideGrid++; continue } | |
| 109 | const cell = (grid.height - 1 - southRow) * grid.width + column, z = xyz[2]!, adjusted = z - corrections[cell]!, next = sums[cell]! + adjusted | |
| 110 | corrections[cell] = (next - sums[cell]!) - adjusted; sums[cell] = next; counts[cell] = counts[cell]! + 1; mins[cell] = Math.min(mins[cell]!, z); maxs[cell] = Math.max(maxs[cell]!, z); selected++ | |
| 111 | } | |
| 112 | for (let i = 0; i < 5; i++) if (byReturn[i] !== v.getUint32(111 + i * 4, true)) return reject() | |
| 113 | for (let axis = 0; axis < 3; axis++) { | |
| 114 | const maximum = v.getFloat64(179 + axis * 16, true), minimum = v.getFloat64(187 + axis * 16, true), tolerance = 8 * Number.EPSILON * Math.max(1, Math.abs(sourceMins[axis]!), Math.abs(sourceMaxs[axis]!)) | |
| 115 | if (!finite(maximum, -1e8, 1e8) || !finite(minimum, -1e8, 1e8) || Math.abs(maximum - sourceMaxs[axis]!) > tolerance || Math.abs(minimum - sourceMins[axis]!) > tolerance) return reject() | |
| 116 | } | |
| 117 | const statistics = Array.from(counts, (n, i) => ({ row: Math.floor(i / grid.width), column: i % grid.width, count: n, minimumM: n ? mins[i]! : null, maximumM: n ? maxs[i]! : null, meanM: n ? sums[i]! / n : null, | |
| 118 | reason: n < grid.minimumPoints ? 'insufficient-points' as const : maxs[i]! - mins[i]! > grid.maximumSpreadM ? 'excessive-spread' as const : 'accepted' as const })) | |
| 119 | if (!statistics.some(cell => cell.reason === 'accepted')) return reject('empty') | |
| 120 | const rows = Array.from({ length: grid.height }, (_, row) => statistics.slice(row * grid.width, (row + 1) * grid.width).map(cell => cell.reason === 'accepted' ? cell.meanM : null)) | |
| 121 | const surface = validateKnownSurface({ version: 1, metadata: d.metadata, geometry: { kind: 'cell-grid', width: grid.width, height: grid.height, westM: grid.westM, southM: grid.southM, cellSizeM: grid.cellSizeM, rows, rowOrder: 'north-to-south', interpretation: 'piecewise-constant-cell-elevation' } }) | |
| 122 | return { surface: { ...surface, geometry: surface.geometry as Extract<SurfaceGeometry, { kind: 'cell-grid' }> }, processing: { | |
| 123 | version: 1, method: 'las12-cell-mean-v1', griddingPerformed: true, supportedAreaEvidence: 'declared-cell-model', pointSamplingCoverageVerified: false, sourceSha256, declarationSha256, format, sourcePointCount: count, selectedPointCount: selected, excluded, | |
| 124 | pointsByReturn: byReturn, pointsByClass: byClass, gps: { type: gpsType, minimum: hasGps ? gpsMin : null, maximum: hasGps ? gpsMax : null }, grid: d.grid, selection: d.selection, | |
| 125 | verticalReference: { mode: 'declared-sidecar', declaredDatumId: d.metadata.reference.verticalDatum.id, embeddedCrs: verticalCrs === null ? null : 'EPSG:' + verticalCrs, unit: 'metre', positive: 'up', coordinateTransformPerformed: false, datumVerified: false }, | |
| 126 | coveredCells: statistics.filter(cell => cell.reason === 'accepted').length, representationErrorBoundM: null, physicalAccuracyVerified: false, classificationVerified: false, cells: statistics, | |
| 127 | } } | |
| 128 | } | |
| 129 | catch (error) { | |
| 130 | if (error instanceof Rejection || error instanceof SurfaceByteRejection) { | |
| 131 | if (error.kind === 'input') throw new TypeError('las-surface-input') | |
| 132 | if (error.kind === 'budget') throw new RangeError('las-surface-budget') | |
| 133 | if (error.kind === 'digest') throw new Error('las-source-sha256-mismatch') | |
| 134 | if (error.kind === 'empty') throw new Error('las-surface-no-supported-cells') | |
| 135 | } | |
| 136 | if (error instanceof SyntaxError || error instanceof RangeError) throw new TypeError('las-surface-input') | |
| 137 | throw new Error('las-surface-unavailable') | |
| 138 | } | |
| 139 | } | |
| @@ -0,0 +1,64 @@ | ||
| 1 | import { chmod, copyFile, mkdir, readFile } from 'node:fs/promises' | |
| 2 | import { join, resolve } from 'node:path' | |
| 3 | import { runBoundedHostProcess } from '../resources/functions/host/process' | |
| 4 | import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasRecordFormat, primaryLasSha256, primaryLasVerticalCrs } from '../tests/fixtures/volumes/las' | |
| 5 | ||
| 6 | const args = process.argv.slice(2) | |
| 7 | if (args.length !== 2) throw new Error('Usage: qualify-las-cli <binary> <new-output-directory>') | |
| 8 | const source = resolve(args[0]!), output = resolve(args[1]!) | |
| 9 | await mkdir(output, { mode: 0o700 }) | |
| 10 | const binary = join(output, 'drone'), sha = (bytes: Uint8Array | string) => new Bun.CryptoHasher('sha256').update(bytes).digest('hex') | |
| 11 | await copyFile(source, binary); await chmod(binary, 0o755) | |
| 12 | await Bun.write(join(output, 'source.las'), primaryLasBytes()) | |
| 13 | await Bun.write(join(output, 'declaration.json'), primaryLasDeclaration()) | |
| 14 | await Bun.write(join(output, 'measurement.json'), JSON.stringify(primaryLasMeasurement())) | |
| 15 | const rows: { name: string, exit: number, stdoutBytes: number, stdoutSha256: string, stderrBytes: number, stderrSha256: string }[] = [] | |
| 16 | async function run(name: string, args: string[], exit = 0) { | |
| 17 | const r = await runBoundedHostProcess([binary, ...args], { cwd: output, timeoutMs: 30000, stdoutBytes: 65536, stderrBytes: 2048, strictUtf8: true, label: 'Copied LAS ' + name }) | |
| 18 | await Bun.write(join(output, name + '.stdout'), r.stdout); await Bun.write(join(output, name + '.stderr'), r.stderr) | |
| 19 | if (r.exitCode !== exit || exit === 0 && r.stderr !== '' || exit !== 0 && r.stdout !== '') throw new Error('Unqualified copied LAS exit: ' + name) | |
| 20 | rows.push({ name, exit, stdoutBytes: Buffer.byteLength(r.stdout), stdoutSha256: sha(r.stdout), stderrBytes: Buffer.byteLength(r.stderr), stderrSha256: sha(r.stderr) }) | |
| 21 | return exit === 0 ? JSON.parse(r.stdout) : r | |
| 22 | } | |
| 23 | const common = ['--format', 'las', '--metadata', join(output, 'declaration.json'), '--measurement', join(output, 'measurement.json')] | |
| 24 | const first = await run('import', ['volumes:import', join(output, 'source.las'), ...common, '--output', join(output, 'dataset')]) | |
| 25 | if (first.report.aboveBaseM3 !== 18 || first.report.coveredAreaM2 !== 3 || first.report.uncoveredAreaM2 !== 1 || first.report.fullBoundarySignedM3 !== null || first.report.physicalAccuracyVerified !== false | |
| 26 | || first.report.uncertainty.overallAccuracyBoundM3 !== null || first.sourceProcessing.sourceSha256 !== primaryLasSha256 || first.sourceProcessing.selectedPointCount !== 5 || first.sourceProcessing.cells !== undefined) throw new Error('Unqualified copied LAS primary facts') | |
| 27 | const manifest = JSON.parse(await readFile(join(output, 'dataset/manifest.json'), 'utf8')) | |
| 28 | for (const file of manifest.files) { | |
| 29 | const bytes = await readFile(join(output, 'dataset', file.path)) | |
| 30 | if (bytes.length !== file.bytes || sha(bytes) !== file.sha256) throw new Error('Unqualified copied LAS dataset hash') | |
| 31 | } | |
| 32 | const processing = JSON.parse(await readFile(join(output, 'dataset/source.gridding.json'), 'utf8')) | |
| 33 | if (manifest.originalSourceSha256 !== primaryLasSha256 || manifest.sourceProcessing.format !== 'las' || sha(await readFile(join(output, 'dataset/source.las'))) !== primaryLasSha256 | |
| 34 | || JSON.stringify(processing.cells.map((c: { meanM: number | null }) => c.meanM)) !== '[3,6,9,null]' || processing.excluded.withheld !== 1 || processing.excluded.classification !== 1 || processing.excluded.outsideGrid !== 1) throw new Error('Unqualified copied LAS processing evidence') | |
| 35 | const replay = await run('replay', ['volumes:process', join(output, 'dataset/request.json')]) | |
| 36 | const asc = await run('asc', ['volumes:import', join(output, 'dataset/surface.asc'), '--format', 'asc', '--metadata', join(output, 'dataset/surface.metadata.json'), '--measurement', join(output, 'measurement.json')]) | |
| 37 | if (JSON.stringify(replay.report) !== JSON.stringify(first.report) || JSON.stringify(asc.report) !== JSON.stringify(first.report)) throw new Error('Unqualified copied LAS replay') | |
| 38 | for (const format of [1, 2, 3] as const) { | |
| 39 | const bytes = primaryLasRecordFormat(format, 1), d = JSON.parse(primaryLasDeclaration()) | |
| 40 | d.metadata.provenance.sourceSha256 = sha(bytes) | |
| 41 | await Bun.write(join(output, 'format.las'), bytes); await Bun.write(join(output, 'format.json'), JSON.stringify(d)) | |
| 42 | const report = await run('format-' + format, ['volumes:import', join(output, 'format.las'), '--format', 'las', '--metadata', join(output, 'format.json'), '--measurement', join(output, 'measurement.json')]) | |
| 43 | if (report.report.aboveBaseM3 !== 18 || report.sourceProcessing.format !== format || report.sourceProcessing.gps.type !== (format === 2 ? 'absent' : 'adjusted-standard-seconds')) throw new Error('Unqualified copied LAS record format') | |
| 44 | } | |
| 45 | const wrong = JSON.parse(primaryLasDeclaration()); wrong.metadata.provenance.sourceSha256 = '0'.repeat(64) | |
| 46 | await Bun.write(join(output, 'wrong.json'), JSON.stringify(wrong)) | |
| 47 | const refused = await run('wrong-digest', ['volumes:import', join(output, 'source.las'), '--format', 'las', '--metadata', join(output, 'wrong.json'), '--measurement', join(output, 'measurement.json')], 1) | |
| 48 | if (refused.stderr.trim() !== 'las-source-sha256-mismatch') throw new Error('Unqualified copied LAS digest refusal') | |
| 49 | for (const [code, accepted] of [[5703, true], [5702, false], [5715, false]] as const) { | |
| 50 | const bytes = primaryLasVerticalCrs(code, true), d = JSON.parse(primaryLasDeclaration()) | |
| 51 | d.metadata.provenance.sourceSha256 = sha(bytes); d.metadata.reference.verticalDatum.id = 'EPSG:' + code | |
| 52 | await Bun.write(join(output, 'vertical.las'), bytes); await Bun.write(join(output, 'vertical.json'), JSON.stringify(d)) | |
| 53 | const result = await run('vertical-' + code, ['volumes:import', join(output, 'vertical.las'), '--format', 'las', '--metadata', join(output, 'vertical.json'), '--measurement', join(output, 'measurement.json')], accepted ? 0 : 1) | |
| 54 | if (accepted ? result.report.aboveBaseM3 !== 18 || result.sourceProcessing.verticalReference.embeddedCrs !== 'EPSG:5703' || result.sourceProcessing.verticalReference.coordinateTransformPerformed !== false || result.sourceProcessing.verticalReference.datumVerified !== false : result.stderr !== 'las-surface-input\n') throw new Error('Unqualified copied LAS vertical interpretation') | |
| 55 | } | |
| 56 | for (const kind of ['declaration', 'measurement', 'output']) { | |
| 57 | const unavailable = join(output, 'PRIVATE_' + kind.toUpperCase()) | |
| 58 | if (kind === 'output') await Bun.write(unavailable, 'PRIVATE_EXISTING_FILE') | |
| 59 | const result = await run('private-' + kind, ['volumes:import', join(output, 'source.las'), '--format', 'las', '--metadata', kind === 'declaration' ? unavailable : join(output, 'declaration.json'), '--measurement', kind === 'measurement' ? unavailable : join(output, 'measurement.json'), '--output', kind === 'output' ? unavailable : join(output, 'PRIVATE_UNPUBLISHED_DATASET')], 1) | |
| 60 | if (result.stderr !== 'las-import-unavailable\n' || result.stderr.includes('PRIVATE') || await Bun.file(join(output, 'PRIVATE_UNPUBLISHED_DATASET/manifest.json')).exists()) throw new Error('Unqualified copied LAS filesystem privacy') | |
| 61 | if (kind === 'output' && await Bun.file(unavailable).text() !== 'PRIVATE_EXISTING_FILE') throw new Error('Copied LAS import changed an existing output') | |
| 62 | } | |
| 63 | await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-las-stockpile-cli-v1', binarySha256: sha(new Uint8Array(await Bun.file(binary).arrayBuffer())), sourceSha256: primaryLasSha256, formats: [0, 1, 2, 3], physicalAccuracyVerified: false, acquisitionPerformed: false, classificationVerified: false, rows }, null, 2)) | |
| 64 | console.log('PASS copied LAS source, four record formats, grid audit, volume replay, vertical interpretation and private refusals') | |
| @@ -7,7 +7,7 @@ import { platform, repository, target } from './repository' | ||
| 7 | 7 | |
| 8 | 8 | async function hash(path: string) { return createHash('sha256').update(await readFile(path)).digest('hex') } |
| 9 | 9 | |
| 10 | /** Real copied commands, signed synthetic bytes and optional retained older binary. Never executes a candidate. */ | |
| 10 | /** Real copied commands, signed synthetic bytes and optional retained format-1-only binary. Never executes a candidate. */ | |
| 11 | 11 | export async function qualifyPortableCandidates(directory: string, source: string, olderBinary?: string) { |
| 12 | 12 | await mkdir(directory, { mode: 0o700 }) |
| 13 | 13 | const binary = join(directory, 'drone'), sourceSha256 = await hash(source) |
| @@ -38,7 +38,13 @@ export async function qualifyPortableCandidates(directory: string, source: strin | ||
| 38 | 38 | const empty = await run(['updates:candidates', store, ...pinned]) |
| 39 | 39 | assert.equal(empty.stateFormat, 1); assert.equal(empty.candidate, null) |
| 40 | 40 | assert.equal(await hash(join(store, 'state.sqlite')), initialHash) |
| 41 | if (olderBinary) assert.deepEqual(await run(['updates:store', store, ...pinned], 0, olderBinary), created) | |
| 41 | if (olderBinary) { | |
| 42 | // A format-1 predecessor cannot emit the later, empty maintenance status field. | |
| 43 | const { maintenanceHandoffs, ...format1Status } = created | |
| 44 | assert.deepEqual(maintenanceHandoffs, []) | |
| 45 | assert.deepEqual(await run(['updates:store', store, ...pinned], 0, olderBinary), format1Status) | |
| 46 | assert.equal(await hash(join(store, 'state.sqlite')), initialHash) | |
| 47 | } | |
| 42 | 48 | await run(['updates:reserve', store, ...pinned, '--repository', repo], 1) |
| 43 | 49 | const prepared = await run(['updates:reserve', store, ...pinned, ...repositoryFlags]) |
| 44 | 50 | assert.equal(prepared.stateFormat, 2); assert.equal(prepared.store.releaseFloor, 9) |
| @@ -85,6 +91,6 @@ export async function qualifyPortableCandidates(directory: string, source: strin | ||
| 85 | 91 | return { evidence: 'copied-standalone-owned-candidates-v1', operations: results.length, sourceSha256, olderSha256, physicalDeploymentAllowed: false } |
| 86 | 92 | } |
| 87 | 93 | if (import.meta.main) { |
| 88 | if (process.argv.length !== 4 && process.argv.length !== 5) throw new Error('Use qualify-candidates.ts NEW_DIRECTORY COMPILED_BINARY [OLDER_BINARY]') | |
| 94 | if (process.argv.length !== 4 && process.argv.length !== 5) throw new Error('Use qualify-candidates.ts NEW_DIRECTORY COMPILED_BINARY [FORMAT1_BINARY]') | |
| 89 | 95 | console.log(JSON.stringify(await qualifyPortableCandidates(resolve(process.argv[2]!), resolve(process.argv[3]!), process.argv[4] ? resolve(process.argv[4]) : undefined))) |
| 90 | 96 | } |
| @@ -0,0 +1,44 @@ | ||
| 1 | import type { SurfaceMetadata } from '../../../resources/functions/drone/volumes/types' | |
| 2 | // Literal binary worksheet, independently encoded from ASPRS LAS1.2 tables. | |
| 3 | // Five selected samples mean north-first cells3,6,9; one missing cell stays unknown. | |
| 4 | export const primaryLasSha256 = '17efc5a7cfe3a40ea8ba8ddb10a1d5330e5621b95866fc3df47eb1c69a24f702' | |
| 5 | const hex = '4c415346000000000000000000000000000000000000000001020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e30039010000010000000014000800000007000000010000000000000000000000000000007b14ae47e17a843f7b14ae47e17a843f7b14ae47e17a843f0000000080841e4100000000d0474f4100000000000000000000000088841e410000000081841e41000000e0d0474f4100000020d0474f410000000000005940000000000000004000004c4153465f50726f6a656374696f6e00af872000000000000000000000000000000000000000000000000000000000000000000001000100000003000004000001000100000c000001006a7f040c000001002923190000007d000000c800000000000901000000004b000000af0000009001000000001201000000007d0000007d00000058020000000009010000000019000000190000002003000000000901000000004b0000004b000000e80300000000090100000000af000000190000001027000000000981000000007d0000004b000000ac2600000000090700000000c800000064000000f40100000000090100000000' | |
| 6 | export function primaryLasBytes(): Uint8Array { return Uint8Array.from(hex.match(/../g)!, byte => Number.parseInt(byte, 16)) } | |
| 7 | export function primaryLasDeclaration(): string { | |
| 8 | const metadata: SurfaceMetadata = { | |
| 9 | version: 1, id: 'synthetic-las-cell-means', | |
| 10 | reference: { horizontalCrs: { kind: 'wgs84-utm', id: 'EPSG:32618', definition: 'Synthetic metre UTM coordinate worksheet', coordinateEpoch: null }, axes: 'EAST_NORTH', horizontalUnit: 'metre', verticalDatum: { kind: 'engineering', id: 'synthetic-zero', definition: 'Declared synthetic zero plane; not a surveyed vertical datum' }, verticalUnit: 'metre', elevationPositive: 'up' }, | |
| 11 | provenance: { evidence: 'synthetic', sourceKind: 'analytic', sourceSha256: primaryLasSha256, acquisition: { status: 'synthetic', sourceId: 'independent-eight-point-worksheet' }, reconstructionOrMeshing: { status: 'unknown', sourceId: null }, boresightCalibration: { status: 'not-applicable', reportId: null }, timeCalibration: { status: 'not-applicable', reportId: null }, groundClassification: { status: 'unknown', description: 'Explicit class1 selection; labels do not verify classification or identify a stockpile surface', reportId: null }, georeferencing: { verticalBoundM: null, horizontalBoundM: null, reportId: null } }, | |
| 12 | } | |
| 13 | return JSON.stringify({ version: 1, profile: 'las12-utm-mean-grid-v1', verticalReference: 'declared-sidecar', metadata, | |
| 14 | selection: { classes: [1], returns: 'all', synthetic: 'exclude' }, | |
| 15 | grid: { westM: 500000, southM: 4100000, width: 2, height: 2, cellSizeM: 1, minimumPoints: 1, maximumSpreadM: 5 } }) | |
| 16 | } | |
| 17 | export function primaryLasMeasurement() { | |
| 18 | return { version: 1, id: 'synthetic-las-volume', boundary: { outer: [[500000,4100000],[500002,4100000],[500002,4100002],[500000,4100002]], holes: [] }, base: { kind: 'plane', anchorM: [500000,4100000,0], gradientEast: 0, gradientNorth: 0, assumption: 'Explicit synthetic zero base, not fitted to point elevations', evidence: 'synthetic', verticalBoundM: null }, reference: null } | |
| 19 | } | |
| 20 | ||
| 21 | // Literal table extensions for the three optional GPS/RGB record layouts. | |
| 22 | export function primaryLasRecordFormat(format: 0 | 1 | 2 | 3, encoding: 0 | 1 = 0): Uint8Array { | |
| 23 | const original = primaryLasBytes(), length = [20, 28, 26, 34][format]! | |
| 24 | const bytes = new Uint8Array(313 + 8 * length); bytes.set(original.subarray(0, 313)) | |
| 25 | const v = new DataView(bytes.buffer); v.setUint16(6, encoding, true); v.setUint8(104, format); v.setUint16(105, length, true) | |
| 26 | for (let i = 0; i < 8; i++) { | |
| 27 | bytes.set(original.subarray(313 + 20 * i, 333 + 20 * i), 313 + i * length) | |
| 28 | if (format === 1 || format === 3) v.setFloat64(333 + i * length, encoding ? -20 + i : 100 + i, true) | |
| 29 | if (format === 2 || format === 3) for (let color = 0; color < 3; color++) v.setUint16(313 + i * length + (format === 2 ? 20 : 28) + 2 * color, 1234, true) | |
| 30 | } | |
| 31 | return bytes | |
| 32 | } | |
| 33 | ||
| 34 | // Extend only the literal GeoKey table; the independently encoded points stay exact. | |
| 35 | export function primaryLasVerticalCrs(code: number, explicitMetreUnit: boolean): Uint8Array { | |
| 36 | const original = primaryLasBytes(), extra = explicitMetreUnit ? 16 : 8 | |
| 37 | const bytes = new Uint8Array(original.length + extra) | |
| 38 | bytes.set(original.subarray(0, 313)); bytes.set(original.subarray(313), 313 + extra) | |
| 39 | const v = new DataView(bytes.buffer) | |
| 40 | v.setUint32(96, 313 + extra, true); v.setUint16(247, 32 + extra, true); v.setUint16(287, explicitMetreUnit ? 5 : 4, true) | |
| 41 | v.setUint16(313, 4096, true); v.setUint16(317, 1, true); v.setUint16(319, code, true) | |
| 42 | if (explicitMetreUnit) { v.setUint16(321, 4099, true); v.setUint16(325, 1, true); v.setUint16(327, 9001, true) } | |
| 43 | return bytes | |
| 44 | } | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "extends": "../tsconfig.json", |
| 3 | 3 | "include": [ |
| 4 | "./unit/workspace-server-render.test.ts", | |
| 4 | 5 | "../storage/framework/types/env.d.ts", |
| 5 | 6 | "../bin/drone.ts", |
| 6 | 7 | "../resources/functions/drone/drivers/**/*.ts", |
| @@ -120,6 +121,7 @@ | ||
| 120 | 121 | "./unit/mapping-*.test.ts", |
| 121 | 122 | "./unit/survey-observations.test.ts", |
| 122 | 123 | "../resources/functions/drone/volumes/**/*.ts", |
| 124 | "../scripts/qualify-las-cli.ts", | |
| 123 | 125 | "../resources/functions/artifacts/**/*.ts", |
| 124 | 126 | "./unit/volumes-*.test.ts", |
| 125 | 127 | "./unit/completed-artifact.test.ts", |
| @@ -0,0 +1,39 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, it } from 'bun:test' | |
| 2 | import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { buildSearchIndex } from '@stacksjs/bunpress' | |
| 6 | import type { BunPressConfig } from '@stacksjs/bunpress' | |
| 7 | ||
| 8 | let root: string | |
| 9 | beforeAll(async () => { | |
| 10 | root = await mkdtemp(join(tmpdir(), 'bunpress-search-base-')) | |
| 11 | await mkdir(join(root, 'docs')) | |
| 12 | await writeFile(join(root, 'index.md'), '# Home\n\nStart here.\n') | |
| 13 | await writeFile(join(root, 'guide.md'), '# Guide\n\nLead.\n\n## Details\n\nDetails.\n') | |
| 14 | await writeFile(join(root, 'docs/repeat.md'), '# Repeat\n\nA real nested docs directory.\n') | |
| 15 | }) | |
| 16 | afterAll(async () => { if (root) await rm(root, { recursive: true, force: true }) }) | |
| 17 | ||
| 18 | describe('search result deployment paths', () => { | |
| 19 | const urls = async (config: Partial<BunPressConfig>) => (await buildSearchIndex(root, { verbose: false, ...config } as BunPressConfig)).map(record => record.url) | |
| 20 | it('keeps page, home and anchored results under an explicit base path', async () => { | |
| 21 | expect(await urls({ basePath: '/docs' })).toEqual(['/docs/docs/repeat', '/docs/guide', '/docs/guide#details', '/docs/']) | |
| 22 | }) | |
| 23 | it('normalizes a nested deployment path using the renderer contract', async () => { | |
| 24 | expect(await urls({ basePath: ' manual/reference/// ' })).toEqual(['/manual/reference/docs/repeat', '/manual/reference/guide', '/manual/reference/guide#details', '/manual/reference/']) | |
| 25 | }) | |
| 26 | it('uses the configured site URL path when no explicit base path exists', async () => { | |
| 27 | expect(await urls({ sitemap: { baseUrl: 'https://example.test/help/docs/' } })).toEqual(['/help/docs/docs/repeat', '/help/docs/guide', '/help/docs/guide#details', '/help/docs/']) | |
| 28 | }) | |
| 29 | it('prefixes locale fallback only after translation selection', async () => { | |
| 30 | const index = await buildSearchIndex(root, { verbose: false, basePath: '/docs', i18n: { locales: ['en', 'es'], defaultLocale: 'en' } } as BunPressConfig) | |
| 31 | expect(index.filter(record => record.locale === 'en').map(record => record.url)).toEqual(['/docs/docs/repeat', '/docs/guide', '/docs/guide#details', '/docs/']) | |
| 32 | expect(index.filter(record => record.locale === 'es').map(record => record.url)).toEqual(['/docs/es/docs/repeat', '/docs/es/guide', '/docs/es/guide#details', '/docs/es']) | |
| 33 | }) | |
| 34 | it('preserves root deployments and an explicit root override', async () => { | |
| 35 | const expected = ['/docs/repeat', '/guide', '/guide#details', '/'] | |
| 36 | expect(await urls({})).toEqual(expected) | |
| 37 | expect(await urls({ basePath: '/', sitemap: { baseUrl: 'https://example.test/help' } })).toEqual(expected) | |
| 38 | }) | |
| 39 | }) | |
| @@ -0,0 +1,119 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { createHash } from 'node:crypto' | |
| 7 | import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasSha256 } from '../fixtures/volumes/las' | |
| 8 | import { importLasSurface } from '../../resources/functions/drone/volumes/las' | |
| 9 | import { validateVolumeRequest } from '../../resources/functions/drone/volumes/engine' | |
| 10 | import { writeVolumeDataset } from '../../resources/functions/drone/volumes/export' | |
| 11 | ||
| 12 | test('native LAS import retains exact source, declaration and grid evidence with a replayable volume dataset', async () => { | |
| 13 | const root = await mkdtemp(join(tmpdir(), 'drone-las-import-')) | |
| 14 | try { | |
| 15 | const raw = primaryLasBytes(), declaration = primaryLasDeclaration(), source = join(root, 'points.las'), metadata = join(root, 'declaration.json'), measurement = join(root, 'measurement.json'), output = join(root, 'dataset') | |
| 16 | await writeFile(source, raw); await writeFile(metadata, declaration); await writeFile(measurement, JSON.stringify(primaryLasMeasurement())) | |
| 17 | const result = await command(['volumes:import', source, '--format', 'las', '--metadata', metadata, '--measurement', measurement, '--output', output]).withTimeout(30000) | |
| 18 | expect(result.exitCode).toBe(0) | |
| 19 | const imported = JSON.parse(result.stdout) | |
| 20 | expect(imported.report.aboveBaseM3).toBe(18) | |
| 21 | expect(imported.report.coveredAreaM2).toBe(3) | |
| 22 | expect(imported.report.fullBoundarySignedM3).toBeNull() | |
| 23 | expect(imported.sourceProcessing.selectedPointCount).toBe(5) | |
| 24 | expect(imported.sourceProcessing.cells).toBeUndefined() | |
| 25 | expect(await readFile(join(output, 'source.las'))).toEqual(Buffer.from(raw)) | |
| 26 | expect(await readFile(join(output, 'source.declaration.json'), 'utf8')).toBe(declaration) | |
| 27 | const processing = JSON.parse(await readFile(join(output, 'source.gridding.json'), 'utf8')) | |
| 28 | expect(processing.cells.map((c: { meanM: number | null }) => c.meanM)).toEqual([3, 6, 9, null]) | |
| 29 | const manifest = JSON.parse(await readFile(join(output, 'manifest.json'), 'utf8')) | |
| 30 | expect(manifest.originalSourceSha256).toBe(primaryLasSha256) | |
| 31 | expect(manifest.sourceProcessing.format).toBe('las') | |
| 32 | for (const f of manifest.files) { | |
| 33 | const bytes = await readFile(join(output, f.path)) | |
| 34 | expect(bytes.length).toBe(f.bytes) | |
| 35 | expect(createHash('sha256').update(bytes).digest('hex')).toBe(f.sha256) | |
| 36 | } | |
| 37 | const replay = await command(['volumes:process', join(output, 'request.json')]).withTimeout(30000) | |
| 38 | expect(replay.exitCode).toBe(0) | |
| 39 | expect(JSON.parse(replay.stdout).report).toEqual(imported.report) | |
| 40 | const again = await command(['volumes:import', join(output, 'source.las'), '--format', 'las', '--metadata', join(output, 'source.declaration.json'), '--measurement', measurement]).withTimeout(30000) | |
| 41 | expect(again.exitCode).toBe(0) | |
| 42 | expect(JSON.parse(again.stdout).report).toEqual(imported.report) | |
| 43 | } | |
| 44 | finally { await rm(root, { recursive: true, force: true }) } | |
| 45 | }, 100000) | |
| 46 | ||
| 47 | test('publication snapshots exact LAS bytes and refuses mismatched geometry or accessor evidence before creating a dataset', async () => { | |
| 48 | const root = await mkdtemp(join(tmpdir(), 'drone-las-publication-')) | |
| 49 | try { | |
| 50 | const bytes = primaryLasBytes(), declarationJson = primaryLasDeclaration(), imported = await importLasSurface(bytes, declarationJson) | |
| 51 | const request = validateVolumeRequest({ ...primaryLasMeasurement(), surface: imported.surface }) | |
| 52 | const pending = writeVolumeDataset(join(root, 'valid'), request, { format: 'las', bytes, declarationJson }) | |
| 53 | bytes.fill(0) | |
| 54 | await pending | |
| 55 | expect(await readFile(join(root, 'valid/source.las'))).toEqual(Buffer.from(primaryLasBytes())) | |
| 56 | const changed = structuredClone(request) | |
| 57 | if (changed.surface.geometry.kind === 'cell-grid') changed.surface.geometry.rows[0]![0] = 4 | |
| 58 | await expect(writeVolumeDataset(join(root, 'changed'), changed, { format: 'las', bytes: primaryLasBytes(), declarationJson })).rejects.toThrow('las-dataset-source-mismatch') | |
| 59 | expect(await Bun.file(join(root, 'changed/manifest.json')).exists()).toBe(false) | |
| 60 | let invoked = 0 | |
| 61 | const unsafe = { format: 'las' as const, declarationJson, get bytes() { invoked++; return primaryLasBytes() } } | |
| 62 | await expect(writeVolumeDataset(join(root, 'accessor'), request, unsafe)).rejects.toThrow('las-dataset-source-input') | |
| 63 | expect(invoked).toBe(0) | |
| 64 | expect(await Bun.file(join(root, 'accessor/manifest.json')).exists()).toBe(false) | |
| 65 | } | |
| 66 | finally { await rm(root, { recursive: true, force: true }) } | |
| 67 | }) | |
| 68 | ||
| 69 | test('native LAS failures do not expose private file or declaration contents or publish a completion marker', async () => { | |
| 70 | const root = await mkdtemp(join(tmpdir(), 'drone-las-private-')) | |
| 71 | try { | |
| 72 | const source = join(root, 'PRIVATE_POINTS.las'), metadata = join(root, 'PRIVATE_DECLARATION.json'), measurement = join(root, 'measurement.json'), output = join(root, 'failed') | |
| 73 | await writeFile(metadata, 'PRIVATE_INPUT_BODY'); await writeFile(measurement, JSON.stringify(primaryLasMeasurement())) | |
| 74 | const result = await command(['volumes:import', source, '--format', 'las', '--metadata', metadata, '--measurement', measurement, '--output', output]).withTimeout(30000) | |
| 75 | expect(result.exitCode).toBe(1) | |
| 76 | expect(result.stdout).toBe('') | |
| 77 | expect(result.stderr).toContain('las-file-unavailable') | |
| 78 | expect(result.stderr).not.toContain('PRIVATE') | |
| 79 | await writeFile(source, primaryLasBytes()) | |
| 80 | const malformed = await command(['volumes:import', source, '--format', 'las', '--metadata', metadata, '--measurement', measurement, '--output', output]).withTimeout(30000) | |
| 81 | expect(malformed.exitCode).toBe(1) | |
| 82 | expect(malformed.stdout).toBe('') | |
| 83 | expect(malformed.stderr).toContain('las-surface-input') | |
| 84 | expect(malformed.stderr).not.toContain('PRIVATE') | |
| 85 | expect(await Bun.file(join(output, 'manifest.json')).exists()).toBe(false) | |
| 86 | } | |
| 87 | finally { await rm(root, { recursive: true, force: true }) } | |
| 88 | }, 70000) | |
| 89 | ||
| 90 | // Real command/filesystem seam: missing sidecars and failed publication must not | |
| 91 | // disclose operator paths. This does not establish filesystem power-loss durability. | |
| 92 | test('native LAS metadata, measurement and output filesystem failures retain fixed private diagnostics', async () => { | |
| 93 | const root = await mkdtemp(join(tmpdir(), 'drone-las-private-files-')) | |
| 94 | try { | |
| 95 | const source = join(root, 'PRIVATE_POINTS.las'), metadata = join(root, 'PRIVATE_DECLARATION.json'), measurement = join(root, 'PRIVATE_MEASUREMENT.json'), output = join(root, 'PRIVATE_OUTPUT') | |
| 96 | await writeFile(source, primaryLasBytes()); await writeFile(measurement, JSON.stringify(primaryLasMeasurement())) | |
| 97 | const run = () => command(['volumes:import', source, '--format', 'las', '--metadata', metadata, '--measurement', measurement, '--output', output]).withTimeout(30000) | |
| 98 | const missingDeclaration = await run() | |
| 99 | expect(missingDeclaration.exitCode).toBe(1) | |
| 100 | expect(missingDeclaration.stdout).toBe('') | |
| 101 | expect(missingDeclaration.stderr).toBe('las-import-unavailable\n') | |
| 102 | expect(missingDeclaration.stderr).not.toContain('PRIVATE') | |
| 103 | await writeFile(metadata, primaryLasDeclaration()); await rm(measurement) | |
| 104 | const missingMeasurement = await run() | |
| 105 | expect(missingMeasurement.exitCode).toBe(1) | |
| 106 | expect(missingMeasurement.stdout).toBe('') | |
| 107 | expect(missingMeasurement.stderr).toBe('las-import-unavailable\n') | |
| 108 | expect(missingMeasurement.stderr).not.toContain('PRIVATE') | |
| 109 | await writeFile(measurement, JSON.stringify(primaryLasMeasurement())); await writeFile(output, 'PRIVATE_EXISTING_FILE') | |
| 110 | const blockedOutput = await run() | |
| 111 | expect(blockedOutput.exitCode).toBe(1) | |
| 112 | expect(blockedOutput.stdout).toBe('') | |
| 113 | expect(blockedOutput.stderr).toBe('las-import-unavailable\n') | |
| 114 | expect(blockedOutput.stderr).not.toContain('PRIVATE') | |
| 115 | expect(await readFile(output, 'utf8')).toBe('PRIVATE_EXISTING_FILE') | |
| 116 | expect(await Bun.file(join(output, 'manifest.json')).exists()).toBe(false) | |
| 117 | } | |
| 118 | finally { await rm(root, { recursive: true, force: true }) } | |
| 119 | }, 100000) | |
| @@ -0,0 +1,168 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { importLasSurface } from '../../resources/functions/drone/volumes/las' | |
| 3 | import { processStockpile } from '../../resources/functions/drone/volumes/engine' | |
| 4 | import { primaryLasBytes, primaryLasDeclaration, primaryLasMeasurement, primaryLasSha256, primaryLasVerticalCrs } from '../fixtures/volumes/las' | |
| 5 | import type { LasGridDeclaration } from '../../resources/functions/drone/volumes/las' | |
| 6 | import { createHash } from 'node:crypto' | |
| 7 | ||
| 8 | function sidecar(bytes: Uint8Array, update?: (d: LasGridDeclaration) => void) { | |
| 9 | const d = JSON.parse(primaryLasDeclaration()) as LasGridDeclaration | |
| 10 | d.metadata.provenance.sourceSha256 = createHash('sha256').update(bytes).digest('hex') | |
| 11 | update?.(d) | |
| 12 | return JSON.stringify(d) | |
| 13 | } | |
| 14 | ||
| 15 | // Public computation seam: real binary bytes to declared cells and independent | |
| 16 | // 18m3 worksheet. It does not establish acquisition/classification/survey accuracy. | |
| 17 | test('a LAS point cloud produces only explicitly supported mean cells and a covered-domain volume', async () => { | |
| 18 | const imported = await importLasSurface(primaryLasBytes(), primaryLasDeclaration()) | |
| 19 | expect(imported.surface.geometry.rows).toEqual([[3, 6], [9, null]]) | |
| 20 | expect(imported.surface.metadata.provenance.sourceSha256).toBe(primaryLasSha256) | |
| 21 | expect(imported.processing.sourcePointCount).toBe(8) | |
| 22 | expect(imported.processing.selectedPointCount).toBe(5) | |
| 23 | expect(imported.processing.excluded).toEqual({ withheld: 1, synthetic: 0, classification: 1, returns: 0, outsideGrid: 1 }) | |
| 24 | expect(imported.processing.coveredCells).toBe(3) | |
| 25 | expect(imported.processing.representationErrorBoundM).toBeNull() | |
| 26 | const report = processStockpile({ ...primaryLasMeasurement(), surface: imported.surface }) | |
| 27 | expect(report.aboveBaseM3).toBe(18) | |
| 28 | expect(report.coveredAreaM2).toBe(3) | |
| 29 | expect(report.uncoveredAreaM2).toBe(1) | |
| 30 | expect(report.status).toBe('partial-coverage') | |
| 31 | expect(report.fullBoundarySignedM3).toBeNull() | |
| 32 | expect(report.uncertainty.overallAccuracyBoundM3).toBeNull() | |
| 33 | expect(report.physicalAccuracyVerified).toBe(false) | |
| 34 | }) | |
| 35 | ||
| 36 | test('point-count, spread, return and class choices retain unknown cells without inventing a ground surface', async () => { | |
| 37 | const bytes = primaryLasBytes() | |
| 38 | const counted = await importLasSurface(bytes, sidecar(bytes, d => { d.grid.minimumPoints = 2 })) | |
| 39 | expect(counted.surface.geometry.rows).toEqual([[3, null], [9, null]]) | |
| 40 | expect(counted.processing.cells[1]!.reason).toBe('insufficient-points') | |
| 41 | const spread = await importLasSurface(bytes, sidecar(bytes, d => { d.grid.maximumSpreadM = 1 })) | |
| 42 | expect(spread.surface.geometry.rows).toEqual([[null, 6], [null, null]]) | |
| 43 | expect(spread.processing.cells[0]!.reason).toBe('excessive-spread') | |
| 44 | const first = await importLasSurface(bytes, sidecar(bytes, d => { d.selection.returns = 'first' })) | |
| 45 | expect(first.surface.geometry.rows).toEqual([[2, 6], [9, null]]) | |
| 46 | expect(first.processing.excluded.returns).toBe(1) | |
| 47 | const classes = await importLasSurface(bytes, sidecar(bytes, d => { d.selection.classes = [7] })) | |
| 48 | expect(classes.surface.geometry.rows).toEqual([[null, null], [null, 99]]) | |
| 49 | expect(classes.processing.classificationVerified).toBe(false) | |
| 50 | await expect(importLasSurface(bytes, sidecar(bytes, d => { d.selection.classes = [2] }))).rejects.toThrow('las-surface-no-supported-cells') | |
| 51 | }) | |
| 52 | ||
| 53 | test('synthetic flags require an explicit choice while withheld samples are always excluded', async () => { | |
| 54 | const bytes = primaryLasBytes(); bytes[328] = 33 | |
| 55 | const excluded = await importLasSurface(bytes, sidecar(bytes)) | |
| 56 | expect(excluded.surface.geometry.rows).toEqual([[4, 6], [9, null]]) | |
| 57 | expect(excluded.processing.excluded.synthetic).toBe(1) | |
| 58 | const included = await importLasSurface(bytes, sidecar(bytes, d => { d.selection.synthetic = 'include' })) | |
| 59 | expect(included.surface.geometry.rows).toEqual([[3, 6], [9, null]]) | |
| 60 | expect(included.processing.excluded.withheld).toBe(1) | |
| 61 | }) | |
| 62 | ||
| 63 | test('signed LAS elevations retain independent cut and fill while declared outer cell edges stay half-open', async () => { | |
| 64 | const negative = primaryLasBytes(), v = new DataView(negative.buffer) | |
| 65 | v.setInt32(321, -200, true); v.setInt32(341, -400, true); v.setFloat64(219, -4, true) | |
| 66 | const imported = await importLasSurface(negative, sidecar(negative)) | |
| 67 | expect(imported.surface.geometry.rows).toEqual([[-3, 6], [9, null]]) | |
| 68 | const report = processStockpile({ ...primaryLasMeasurement(), surface: imported.surface }) | |
| 69 | expect(report.aboveBaseM3).toBe(15) | |
| 70 | expect(report.belowBaseM3).toBe(3) | |
| 71 | expect(report.signedTopMinusBaseM3).toBe(12) | |
| 72 | const edges = primaryLasBytes(), edge = new DataView(edges.buffer) | |
| 73 | edge.setInt32(313, 0, true); edge.setInt32(377, 0, true) | |
| 74 | edge.setInt32(453, 100, true); edge.setInt32(457, 200, true) | |
| 75 | edge.setFloat64(179, 500001.75, true); edge.setFloat64(187, 500000, true) | |
| 76 | edge.setFloat64(195, 4100002, true); edge.setFloat64(203, 4100000, true) | |
| 77 | const bounded = await importLasSurface(edges, sidecar(edges)) | |
| 78 | expect(bounded.surface.geometry.rows).toEqual([[3, 6], [9, null]]) | |
| 79 | expect(bounded.processing.excluded.outsideGrid).toBe(1) | |
| 80 | }) | |
| 81 | ||
| 82 | // Independent record-layout extensions to the literal worksheet, not production encoders. | |
| 83 | test('LAS1.2 formats1,2,3 preserve coordinate means and explicitly reported GPS time conventions', async () => { | |
| 84 | const original = primaryLasBytes() | |
| 85 | for (const [format, length] of [[1, 28], [2, 26], [3, 34]] as const) { | |
| 86 | for (const globalEncoding of [0, 1]) { | |
| 87 | const bytes = new Uint8Array(313 + 8 * length); bytes.set(original.subarray(0, 313)) | |
| 88 | const v = new DataView(bytes.buffer); v.setUint16(6, globalEncoding, true); v.setUint8(104, format); v.setUint16(105, length, true) | |
| 89 | for (let i = 0; i < 8; i++) { | |
| 90 | bytes.set(original.subarray(313 + 20 * i, 333 + 20 * i), 313 + i * length) | |
| 91 | if (format !== 2) v.setFloat64(333 + i * length, globalEncoding ? -20 + i : 100 + i, true) | |
| 92 | if (format !== 1) for (let color = 0; color < 3; color++) v.setUint16(313 + i * length + (format === 2 ? 20 : 28) + 2 * color, 1234, true) | |
| 93 | } | |
| 94 | const parsed = await importLasSurface(bytes, sidecar(bytes)) | |
| 95 | expect(parsed.surface.geometry.rows).toEqual([[3, 6], [9, null]]) | |
| 96 | expect(parsed.processing.format).toBe(format) | |
| 97 | expect(parsed.processing.gps).toEqual(format === 2 ? { type: 'absent', minimum: null, maximum: null } : globalEncoding ? { type: 'adjusted-standard-seconds', minimum: -20, maximum: -13 } : { type: 'week-seconds', minimum: 100, maximum: 107 }) | |
| 98 | } | |
| 99 | } | |
| 100 | }) | |
| 101 | ||
| 102 | test('admission snapshots native visible bytes before await without invoking caller properties', async () => { | |
| 103 | const owner = new Uint8Array(500); owner.set(primaryLasBytes(), 11) | |
| 104 | const view = owner.subarray(11, 484) | |
| 105 | for (const key of ['length', 'byteLength', 'byteOffset', 'buffer', Symbol.iterator]) Object.defineProperty(view, key, { get() { throw new Error('PRIVATE_ACCESSOR') } }) | |
| 106 | const pending = importLasSurface(view, primaryLasDeclaration()) | |
| 107 | owner.fill(0) | |
| 108 | const first = await pending | |
| 109 | expect(first.surface.geometry.rows).toEqual([[3, 6], [9, null]]) | |
| 110 | first.surface.geometry.rows[0]![0] = 999 | |
| 111 | const second = await importLasSurface(primaryLasBytes(), primaryLasDeclaration()) | |
| 112 | expect(second.surface.geometry.rows[0]![0]).toBe(3) | |
| 113 | for (const bad of [null, [], new Proxy(primaryLasBytes(), {}), new Uint8Array(new SharedArrayBuffer(473)), new Uint8Array(8388609)]) await expect(importLasSurface(bad as Uint8Array, primaryLasDeclaration())).rejects.toThrow(/^las-surface-(input|budget)$/) | |
| 114 | }) | |
| 115 | ||
| 116 | test('malformed or contradictory LAS records cannot be laundered through a matching source digest', async () => { | |
| 117 | const changes: ((v: DataView, bytes: Uint8Array) => void)[] = [ | |
| 118 | v => v.setUint8(25, 4), v => v.setUint8(104, 128), v => v.setUint16(105, 21, true), v => v.setUint32(96, 312, true), | |
| 119 | v => v.setUint32(107, 200001, true), v => v.setUint32(111, 8, true), v => v.setFloat64(131, 0, true), | |
| 120 | v => v.setFloat64(155, NaN, true), v => v.setFloat64(179, 500001, true), v => v.setUint16(311, 9002, true), | |
| 121 | v => v.setUint16(303, 32619, true), v => v.setUint16(299, 1, true), v => v.setUint8(327, 0), v => v.setInt8(329, -91), | |
| 122 | ] | |
| 123 | for (const change of changes) { | |
| 124 | const bytes = primaryLasBytes(); change(new DataView(bytes.buffer), bytes) | |
| 125 | await expect(importLasSurface(bytes, sidecar(bytes))).rejects.toThrow(/^las-surface-(input|budget)$/) | |
| 126 | } | |
| 127 | const good = primaryLasBytes() | |
| 128 | await expect(importLasSurface(good.subarray(0, 472), sidecar(good.subarray(0, 472)))).rejects.toThrow('las-surface-input') | |
| 129 | const trailer = new Uint8Array(474); trailer.set(good) | |
| 130 | await expect(importLasSurface(trailer, sidecar(trailer))).rejects.toThrow('las-surface-input') | |
| 131 | good[400] = good[400]! ^ 1 | |
| 132 | await expect(importLasSurface(good, primaryLasDeclaration())).rejects.toThrow('las-source-sha256-mismatch') | |
| 133 | for (const declaration of ['PRIVATE_INVALID_JSON', '\uFEFF' + primaryLasDeclaration(), sidecar(primaryLasBytes(), d => { d.metadata.reference.horizontalCrs.id = 'EPSG:32619' }), sidecar(primaryLasBytes(), d => { d.selection.classes = [1, 1] })]) { | |
| 134 | let message = '' | |
| 135 | try { await importLasSurface(primaryLasBytes(), declaration) } | |
| 136 | catch (error) { message = (error as Error).message } | |
| 137 | expect(message).toBe('las-surface-input') | |
| 138 | expect(message).not.toContain('PRIVATE') | |
| 139 | } | |
| 140 | }) | |
| 141 | ||
| 142 | // Matching labels cannot change the embedded vertical CRS's units or axis. | |
| 143 | test('a matching sidecar cannot interpret a feet or depth vertical CRS as metres-up', async () => { | |
| 144 | for (const code of [5702, 5715]) { | |
| 145 | for (const explicitMetreUnit of [false, true]) { | |
| 146 | const bytes = primaryLasVerticalCrs(code, explicitMetreUnit) | |
| 147 | await expect(importLasSurface(bytes, sidecar(bytes, d => { d.metadata.reference.verticalDatum.id = `EPSG:${code}` }))).rejects.toThrow('las-surface-input') | |
| 148 | } | |
| 149 | } | |
| 150 | }) | |
| 151 | ||
| 152 | test('verified metre-up embedded vertical CRS labels preserve numeric elevations and explicit unverified datum evidence', async () => { | |
| 153 | for (const code of [3855, 5701, 5703, 5714, 5773, 5783]) { | |
| 154 | for (const explicitMetreUnit of [false, true]) { | |
| 155 | const bytes = primaryLasVerticalCrs(code, explicitMetreUnit) | |
| 156 | const imported = await importLasSurface(bytes, sidecar(bytes, d => { d.metadata.reference.verticalDatum.id = `EPSG:${code}` })) | |
| 157 | expect(imported.surface.geometry.rows).toEqual([[3, 6], [9, null]]) | |
| 158 | expect(imported.processing.verticalReference).toEqual({ mode: 'declared-sidecar', declaredDatumId: `EPSG:${code}`, embeddedCrs: `EPSG:${code}`, unit: 'metre', positive: 'up', coordinateTransformPerformed: false, datumVerified: false }) | |
| 159 | } | |
| 160 | } | |
| 161 | const unknown = primaryLasVerticalCrs(65534, true) | |
| 162 | await expect(importLasSurface(unknown, sidecar(unknown, d => { d.metadata.reference.verticalDatum.id = 'EPSG:65534' }))).rejects.toThrow('las-surface-input') | |
| 163 | const different = primaryLasVerticalCrs(5703, true) | |
| 164 | await expect(importLasSurface(different, sidecar(different, d => { d.metadata.reference.verticalDatum.id = 'EPSG:5773' }))).rejects.toThrow('las-surface-input') | |
| 165 | const absent = await importLasSurface(primaryLasBytes(), primaryLasDeclaration()) | |
| 166 | expect(absent.processing.verticalReference.embeddedCrs).toBeNull() | |
| 167 | expect(absent.processing.verticalReference.datumVerified).toBe(false) | |
| 168 | }) | |
| @@ -0,0 +1,51 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtemp, rm } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | import { log, registerTransport } from '@stacksjs/logging' | |
| 6 | import { report } from '@stacksjs/logging/runtime' | |
| 7 | import type { LogRecord } from '@stacksjs/logging' | |
| 8 | import { renderTemplate } from '@stacksjs/stx' | |
| 9 | ||
| 10 | for (const [page, heading] of [ | |
| 11 | ['diagnostics', 'Inspect what happened.'], | |
| 12 | ['volumes', 'Stockpile volume analysis'], | |
| 13 | ['application', 'Prescribed application'], | |
| 14 | ] as const) { | |
| 15 | test(`${page} server render keeps client analysis out of the native error reporter`, async () => { | |
| 16 | const root = resolve(import.meta.dir, '../..') | |
| 17 | const directory = await mkdtemp(join(tmpdir(), 'drone-workspace-render-')) | |
| 18 | const previous = Object.getOwnPropertyDescriptor(globalThis, 'report') | |
| 19 | const errors: LogRecord[] = [] | |
| 20 | const detach = registerTransport({ name: 'workspace-render-observer', level: 'error', log: record => { errors.push(record) } }) | |
| 21 | try { | |
| 22 | // Use the actual production global, with an observing transport rather than a mock reporter. | |
| 23 | Object.defineProperty(globalThis, 'report', { configurable: true, writable: true, value: report }) | |
| 24 | report(new Error('workspace-render-observer-control')) | |
| 25 | await log.flush() | |
| 26 | expect(errors).toHaveLength(1) | |
| 27 | errors.length = 0 | |
| 28 | const html = await renderTemplate(join(root, 'resources/views', `${page}.stx`), { | |
| 29 | injectCSS: true, | |
| 30 | options: { | |
| 31 | root: join(root, 'resources'), | |
| 32 | componentsDir: join(root, 'resources/components'), | |
| 33 | composablesDir: join(directory, 'composables'), | |
| 34 | storesDir: join(directory, 'stores'), | |
| 35 | stateDir: join(directory, 'stx'), | |
| 36 | envFile: false, | |
| 37 | strict: { enabled: true, bundlerFallback: 'error' }, | |
| 38 | }, | |
| 39 | }) | |
| 40 | await log.flush() | |
| 41 | expect(html).toContain(heading) | |
| 42 | expect(errors).toEqual([]) | |
| 43 | } | |
| 44 | finally { | |
| 45 | detach() | |
| 46 | if (previous) Object.defineProperty(globalThis, 'report', previous) | |
| 47 | else Reflect.deleteProperty(globalThis, 'report') | |
| 48 | await rm(directory, { recursive: true, force: true }) | |
| 49 | } | |
| 50 | }, 30_000) | |
| 51 | } | |