diff --git a/app/javascript/projects/modelling/components/index.ts b/app/javascript/projects/modelling/components/index.ts index 0e5e6239..d8107e04 100644 --- a/app/javascript/projects/modelling/components/index.ts +++ b/app/javascript/projects/modelling/components/index.ts @@ -36,6 +36,7 @@ import { ORValComponent } from "./orval_component" import { IMDComponent } from "./imd_component" import { HedgerowComponent } from "./hedgerow_component" import { ProjectPermissions } from "../../project_editor" +import { SoilComponent } from "./soil_component" export interface ProjectProperties { extent: Extent @@ -75,6 +76,7 @@ export function createDefaultComponents(saveMapLayer: SaveMapLayer, saveModel: S new CROMEComponent(extent, zoom, mask, maskLayer, maskCQL), new ATIComponent(extent, zoom, mask, maskLayer, maskCQL), new DesignationsComponent(extent, zoom, mask, maskLayer, maskCQL), + new SoilComponent(projectProps), // Outputs new MapLayerComponent(saveMapLayer), diff --git a/app/javascript/projects/modelling/components/soil_component.ts b/app/javascript/projects/modelling/components/soil_component.ts new file mode 100644 index 00000000..809581a7 --- /dev/null +++ b/app/javascript/projects/modelling/components/soil_component.ts @@ -0,0 +1,257 @@ +import { BaseComponent } from "./base_component" +import { NodeData, WorkerInputs, WorkerOutputs } from 'rete/types/core/data' +import { Input, Node, Output, Socket } from 'rete' +import { ProjectProperties } from "." +import { Extent } from "ol/extent" +import { booleanDataSocket, categoricalDataSocket, numericDataSocket } from "../socket_types" +import { createXYZ } from "ol/tilegrid" +import { retrieveISRICData } from "../model_retrieval" +import { CategoricalTileGrid, NumericTileGrid } from "../tile_grid" +import { TypedArray } from "d3" +import { maskFromExtentAndShape } from "../bounding_box" +import { SelectControl, SelectControlOptions } from "../controls/select" + +const ERBSoilTypes = [ + "Acrisols", + "Albeluvisols", + "Alisols", + "Andosols", + "Arenosols", + "Calcisols", + "Cambisols", + "Chernozems", + "Cryosols", + "Durisols", + "Ferralsols", + "Fluvisols", + "Gleysols", + "Gypsisols", + "Histosols", + "Kastanozems", + "Leptosols", + "Lixisols", + "Luvisols", + "Nitisols", + "Phaeozems", + "Planosols", + "Plinthosols", + "Podzols", + "Regosols", + "Solonchaks", + "Solonetz", + "Stagnosols", + "Umbrisols", + "Vertisols" +] + +interface SoilGridOptions { + SCOId : number + name : string + map: string + coverageId: string + outputSocket: Socket +} + +const SoilGrids : SelectControlOptions[] = [ + { id: 0, name: 'WRB (most probable)'}, + { id: 1, name: 'WRB (probability)' } +] + +const SoilGridOptions : SoilGridOptions[] = [ + { + SCOId: 0, + name: 'All', + map: 'wrb', + coverageId: 'MostProbable', + outputSocket: categoricalDataSocket + } +] + +ERBSoilTypes.forEach((soilType, index) => { + SoilGridOptions.push({ + SCOId: 0, + name: soilType, + map: 'wrb', + coverageId: 'MostProbable', + outputSocket: booleanDataSocket + }) + SoilGridOptions.push({ + SCOId: 1, + name: soilType, + map: 'wrb', + coverageId: soilType, + outputSocket: numericDataSocket + + }) +}) + +async function renderCategoricalData(extent: Extent, zoom: number, maskMode: boolean, maskLayer: string, maskCQL: string) { + + const tileGrid = createXYZ() + const mask = await maskFromExtentAndShape(extent, zoom, maskLayer, maskCQL, maskMode) + const outputTileRange = tileGrid.getTileRangeForExtentAndZ(extent, zoom) + + const geotiff = await retrieveISRICData(extent, 'MostProbable', 'wrb', outputTileRange) + + + const rasters = await geotiff.readRasters({ bbox: extent, width: outputTileRange.getWidth(), height: outputTileRange.getHeight() }) + const image = await geotiff.getImage() + + + const result = new CategoricalTileGrid( + zoom, + outputTileRange.minX, + outputTileRange.minY, + outputTileRange.getWidth(), + outputTileRange.getHeight() + ) + + for (let i = 0; i < (rasters[0] as TypedArray).length; i++) { + + let x = (outputTileRange.minX + i % image.getWidth()) + let y = (outputTileRange.minY + Math.floor(i / image.getWidth())) + result.set(x, y, mask.get(x, y) ? rasters[0][i]+1 : 255) + + } + + result.setLabels(new Map(ERBSoilTypes.map((soilType, index) => [index+1, soilType]))) + + return result +} + +async function renderNumericData(extent: Extent, zoom: number, maskMode: boolean, maskLayer: string, maskCQL: string, map: string, coverageId: string) { + + const tileGrid = createXYZ() + const mask = await maskFromExtentAndShape(extent, zoom, maskLayer, maskCQL, maskMode) + const outputTileRange = tileGrid.getTileRangeForExtentAndZ(extent, zoom) + + const geotiff = await retrieveISRICData(extent, coverageId, map, outputTileRange) + + const rasters = await geotiff.readRasters({ bbox: extent, width: outputTileRange.getWidth(), height: outputTileRange.getHeight() }) + const image = await geotiff.getImage() + + const result = new NumericTileGrid( + zoom, + outputTileRange.minX, + outputTileRange.minY, + outputTileRange.getWidth(), + outputTileRange.getHeight() + ) + + for (let i = 0; i < (rasters[0] as TypedArray).length; i++) { + + let x = (outputTileRange.minX + i % image.getWidth()) + let y = (outputTileRange.minY + Math.floor(i / image.getWidth())) + + const value = rasters[0][i] + result.set(x, y, mask.get(x, y) ? (value === 255 ? NaN : value) : NaN) + + } + + return result +} + +export class SoilComponent extends BaseComponent { + projectZoom: number + projectExtent: Extent + maskMode: boolean + maskLayer: string + maskCQL: string + + + constructor(projectProps: ProjectProperties) { + super("ISRIC Soil Data") + this.category = "Inputs" + this.projectZoom = projectProps.zoom + this.projectExtent = projectProps.extent + this.maskMode = projectProps.mask + this.maskLayer = projectProps.maskLayer + this.maskCQL = projectProps.maskCQL + } + + changeOutputs(node: Node) { + + node.getConnections().forEach(c => { + if (c.output.node !== node) { + this.editor?.removeConnection(c) + } + }) + node.getConnections().forEach(c => this.editor?.removeConnection(c)) + + Array.from(node.outputs.values()).forEach(output => node.removeOutput(output)) + + this.updateOutputs(node) + + } + + updateOutputs(node: Node) { + + const soilgridId = node.data.soilgridId || 0 + // TODO - add outputs based on soilgridId + const soilGridOpts = SoilGridOptions.filter(opt => opt.SCOId == soilgridId) + soilGridOpts.forEach( + opt => node.addOutput(new Output(`${opt.name}-${opt.map}`, opt.name, opt.outputSocket)) + ) + node.update() + + } + + async builder(node: Node) { + + node.meta.toolTip = "Data from ISRIC SoilGrids API." + node.meta.toolTipLink = "https://www.isric.org/" + + node.addControl( + new SelectControl( + this.editor, + 'soilgridId', + () => SoilGrids, + () => this.changeOutputs(node), + 'Soil Grid' + ) + ) + + this.updateOutputs(node) + + } + + + async worker(node: NodeData, inputs: WorkerInputs, outputs: WorkerOutputs, ...args: unknown[]) { + + const soilgridId = node.data.soilgridId || 0 + const soilGrid = SoilGrids.find(opt => opt.id == soilgridId) + const soilGridOpts = SoilGridOptions.filter(opt => opt.SCOId == soilgridId) + let catData: CategoricalTileGrid | undefined = undefined + + const promises = soilGridOpts.filter( + opt => node.outputs[`${opt.name}-${opt.map}`].connections.length > 0 + ).map(async opt => { + if(soilGrid?.name == 'WRB (most probable)') { + + if(!catData) { + catData = await renderCategoricalData(this.projectExtent, this.projectZoom, this.maskMode, this.maskLayer, this.maskCQL) + } + + switch(opt.name) { + case 'All': + outputs[`${opt.name}-${opt.map}`] = catData + break + default: + outputs[`${opt.name}-${opt.map}`] = catData?.getBoolFromLabel(opt.name) + break + } + + }else{ + + outputs[`${opt.name}-${opt.map}`] = await renderNumericData(this.projectExtent, this.projectZoom, this.maskMode, this.maskLayer, this.maskCQL, opt.map, opt.coverageId) + + } + }) + + await Promise.all(promises) + + //outputs["WRB"] = await renderCategoricalData(this.projectExtent, this.projectZoom, this.maskMode, this.maskLayer, this.maskCQL) + + } + +} \ No newline at end of file diff --git a/app/javascript/projects/modelling/model_retrieval.ts b/app/javascript/projects/modelling/model_retrieval.ts index 017fd5d4..2e581fe3 100644 --- a/app/javascript/projects/modelling/model_retrieval.ts +++ b/app/javascript/projects/modelling/model_retrieval.ts @@ -4,6 +4,7 @@ import * as GeoTIFF from 'geotiff/dist-browser/geotiff' import { Extent } from 'ol/extent' import { bboxFromExtent } from './bounding_box' +// Returns a GeoTIFF object from a WMS server. Useful for some categorical/boolean data but may be susceptible to data loss. Fatest option usually export async function retrieveModelData(extent: Extent, source: string, tileRange: any, style?: string) { // Uses WMS server: Returns data between 0 and 255 @@ -39,6 +40,7 @@ export async function retrieveModelData(extent: Extent, source: string, tileRang } +// Returns a GeoTIFF object from a WCS server. Useful for continuous data but may be slower. export async function retrieveModelDataWCS(extent: Extent, source: string, tileRange: any) { // Uses WCS server: Returns raw data @@ -68,4 +70,33 @@ export async function retrieveModelDataWCS(extent: Extent, source: string, tileR const tiff = await GeoTIFF.fromArrayBuffer(arrayBuffer) return tiff +} + +// For interaction with ISRIC SoilGrids API +export async function retrieveISRICData(extent: Extent, coverageId: string, map: string, tileRange: any) { + + const server = `https://maps.isric.org/mapserv?map=/map/${map}.map&` + const [width, height] = [tileRange.getWidth(), tileRange.getHeight()] + const response = await fetch( + server + new URLSearchParams( + { + service: 'WCS', + version: '1.0.0', + request: 'GetCoverage', + COVERAGE: coverageId, + FORMAT: 'GEOTIFF_INT16', + WIDTH: width, + HEIGHT: height, + CRS: 'EPSG:3857', + RESPONSE_CRS: 'EPSG:3857', + BBOX: extent.toString() + } + ) + ) + + const arrayBuffer = await response.arrayBuffer() + const tiff = await GeoTIFF.fromArrayBuffer(arrayBuffer) + + return tiff + } \ No newline at end of file diff --git a/app/javascript/projects/modelling/tile_grid.ts b/app/javascript/projects/modelling/tile_grid.ts index 54ba0e92..7d0a710c 100644 --- a/app/javascript/projects/modelling/tile_grid.ts +++ b/app/javascript/projects/modelling/tile_grid.ts @@ -390,6 +390,22 @@ export class CategoricalTileGrid extends TileGrid { this.minMax = [0, this.labels.size] } + getBoolFromLabel(label: string): BooleanTileGrid { + + const boolGrid = new BooleanTileGrid(this.zoom, this.x, this.y, this.width, this.height) + + const key = Array.from(this.labels).find(([key, value]) => value === label)?.[0] + + this.iterate((x, y, value) => { + if (value === key) { + boolGrid.set(x, y, true) + } + }) + + return boolGrid + + } + getMinMax() { if (this.minMax == null) { //no labels given.