UNPKG

@seasketch/geoprocessing

Version:

Geoprocessing and reporting framework for SeaSketch 2.0

212 lines (196 loc) • 6.86 kB
import { Polygon, GEOBLAZE_RASTER_STATS, Sketch, SketchCollection, Feature, MultiPolygon, FeatureCollection, MetricDimension, } from "../../types/index.js"; import geoblaze, { Georaster } from "geoblaze"; import { StatsObject, CalcStatsOptions, Histogram, } from "../../types/geoblaze.js"; import { toRasterProjection, geoblazeDefaultStatValues } from "./geoblaze.js"; import cloneDeep from "lodash/cloneDeep.js"; import { callWithRetry } from "../../helpers/callWithRetry.js"; // default values for all supported raster stats, beyond just geoblaze.stats export const defaultStatValues = { ...cloneDeep(geoblazeDefaultStatValues), area: 0, histogram: {}, }; /** * options accepted by rasterStats * @extends CalcStatsOptions - options passed on to calcStats and calc-stats package. See See https://github.com/DanielJDufour/calc-stats/tree/main?tab=readme-ov-file#advanced-usage */ export interface RasterStatsOptions extends CalcStatsOptions { /** single sketch or sketch collection filter to overlap with raster when calculating metrics. */ feature?: | Feature<Polygon | MultiPolygon> | FeatureCollection<Polygon | MultiPolygon> | Sketch<Polygon | MultiPolygon> | SketchCollection<Polygon | MultiPolygon>; /** undocumented filter function (how different from filter option above?), for example a => a > 0 will filter out pixels greater than zero such that 'valid' is number of valid pixels greater than 0 */ filterFn?: (cellValue: number) => boolean; /** Optional number of bands in the raster, defaults to 1, used to initialize zero values */ numBands?: number; /** If categorical raster, set to true */ categorical?: boolean; /** If categorical raster, metric property name that categories are organized. Defaults to classId */ categoryMetricProperty?: MetricDimension; /** If categorical raster, array of values to create metrics for. Any values not provided won't be counted */ categoryMetricValues?: string[]; } /** * Calculates over 10 different raster statistics, optionally constrains to raster cells overlapping with feature (zonal statistics). * Defaults to calculating only sum stat * If no raster cells with value found, returns 0 or null value for each stat as appropriate. * @throws any errors */ export const rasterStats = async ( raster: Georaster, options: RasterStatsOptions = {}, ) => { const { numBands = 1, stats = ["sum"], feature, filterFn, categorical = false, categoryMetricValues, ...restCalcOptions } = options; let statsToCalculate = [...stats]; let statsToPublish = [...stats]; // If area is included in stats list, then also include valid stat which is needed to calculate area later if (stats.includes("area")) statsToCalculate.push("valid"); // if categorical, override stats to only include histogram if (categorical) { statsToCalculate = ["histogram"]; statsToPublish = ["histogram"]; } const projectedFeat = toRasterProjection(raster, feature); const finalStats: StatsObject[] = []; let statsByBand: StatsObject[] = []; // Package default values for only published stats. Enhance histogram default with categories if provided const defaultStats: StatsObject[] = []; for (let i = 0; i < numBands; i++) { defaultStats[i] = {}; for (const element of statsToPublish) { if (element === "histogram" && categorical && categoryMetricValues) { const hist = {}; for (const c of categoryMetricValues) hist[c] = 0; // load zero for each histogram category defaultStats[i][element] = hist; } else { defaultStats[i][element] = defaultStatValues[element]; } } } try { if (categorical) { const histogram = (await callWithRetry( geoblaze.histogram, [ raster, projectedFeat, { scaleType: "nominal", }, ], { ifErrorMsgContains: "fetch failed", }, )) as Histogram[]; // If no overlap, return default values if ( histogram.length === 0 || (histogram.length === 1 && Object.keys(histogram[0]).length === 0) ) { return defaultStats; } else { statsByBand = histogram.map((h) => { const hist = {}; if (!categoryMetricValues || categoryMetricValues.length === 0) { return { histogram: h }; } else { for (const c of categoryMetricValues) hist[c] = h[c] ?? 0; return { histogram: hist }; } }); } } else { statsByBand = await callWithRetry( geoblaze.stats, [ raster, projectedFeat, { stats: statsToCalculate.filter((stat) => GEOBLAZE_RASTER_STATS.includes(stat), ), // filter to only native geoblaze stats ...restCalcOptions, }, filterFn, ], { ifErrorMsgContains: "fetch failed" }, ); } for (const statBand of statsByBand) { // Calculate area if (statsToCalculate.includes("area")) { statBand.area = statBand.valid ? statBand.valid * raster.pixelHeight * raster.pixelWidth : 0; } // Remove valid stat if only added as temp for calculating area if (!statsToPublish?.includes("valid")) delete statBand.valid; // Transfer stats, falling back to default values if not a number const finalStatsBand: StatsObject = statsToPublish.reduce( (soFar, curStat) => { const rawValue = statBand[curStat]; const curValue = rawValue === undefined || (!categorical && (Number.isNaN(rawValue) || typeof rawValue !== "number")) ? defaultStatValues[curStat] : rawValue; return { ...soFar, [curStat]: curValue }; }, {}, ); // Transfer calculated stats if valid number finalStats.push(finalStatsBand); } } catch (error: unknown) { // swallow certain errors and return default stats instead of rethrowing if ( typeof error === "string" && error.includes("No Values were found in the given geometry") ) { console.log( "rasterStats returning default values for error:", "No Values were found in the given geometry", ); return defaultStats; } if ( error instanceof Error && error.message.includes( "Cannot read properties of undefined (reading 'vrm')", ) ) { console.log( "rasterStats returning default values for error:", "Cannot read properties of undefined (reading 'vrm')", ); return defaultStats; } // rethrow all other errors throw error; } return finalStats; };