diff --git a/api/apps/geoprocessing/src/modules/planning-units/planning-units.job.ts b/api/apps/geoprocessing/src/modules/planning-units/planning-units.job.ts index 49b98a0323..ae89b36c28 100644 --- a/api/apps/geoprocessing/src/modules/planning-units/planning-units.job.ts +++ b/api/apps/geoprocessing/src/modules/planning-units/planning-units.job.ts @@ -16,8 +16,18 @@ type CustomPlanningAreaJob = Required< PlanningUnitsJob, 'countryId' | 'adminRegionId' | 'adminAreaLevel1Id' | 'adminAreaLevel2Id' > ->; -type RegularPlanningAreaJob = Omit; +> & { planningUnitGridShape: RegularPlanningUnitGridShape }; + +export type RegularPlanningAreaJob = Omit< + PlanningUnitsJob, + 'planningAreaId' +> & { + planningUnitGridShape: RegularPlanningUnitGridShape; +}; + +export type RegularPlanningUnitGridShape = + | PlanningUnitGridShape.Hexagon + | PlanningUnitGridShape.Square; function isCustomPlanningAreaJob( job: PlanningUnitsJob, @@ -25,15 +35,25 @@ function isCustomPlanningAreaJob( return job.planningAreaId !== undefined; } +export const calculateGridSize: { + [value in RegularPlanningUnitGridShape]: (areaKm2: number) => number; +} = { + [PlanningUnitGridShape.Square]: (areaKm2) => Math.sqrt(areaKm2) * 1000, + [PlanningUnitGridShape.Hexagon]: (areaKm2) => + Math.sqrt((2 * areaKm2) / (3 * Math.sqrt(3))) * 1000, +}; + +export const gridShapeFnMapping: { + [value in RegularPlanningUnitGridShape]: string; +} = { + [PlanningUnitGridShape.Hexagon]: 'ST_HexagonGrid', + [PlanningUnitGridShape.Square]: 'ST_SquareGrid', +}; + @Injectable() export class PlanningUnitsJobProcessor { private logger = new Logger('planning-units-job-processor'); - private gridShapeFnMapping: { [value in PlanningUnitGridShape]?: string } = { - [PlanningUnitGridShape.Hexagon]: 'ST_HexagonGrid', - [PlanningUnitGridShape.Square]: 'ST_SquareGrid', - }; - constructor( @InjectEntityManager() private readonly entityManager: EntityManager, @@ -85,8 +105,10 @@ export class PlanningUnitsJobProcessor { private getCustomPlanningAreaGridSubquery( data: CustomPlanningAreaJob, ): string { - const gridFn = this.gridShapeFnMapping[data.planningUnitGridShape]; - const size = Math.sqrt(data.planningUnitAreakm2) * 1000; + const gridFn = gridShapeFnMapping[data.planningUnitGridShape]; + const size = calculateGridSize[data.planningUnitGridShape]( + data.planningUnitAreakm2, + ); return ` WITH region AS ( @@ -115,8 +137,8 @@ export class PlanningUnitsJobProcessor { adminAreaLevel1Id ? `gid_1 = '${adminAreaLevel1Id}'` : 'gid_1 is null', adminAreaLevel2Id ? `gid_2 = '${adminAreaLevel2Id}'` : 'gid_2 is null', ]; - const gridFn = this.gridShapeFnMapping[planningUnitGridShape]; - const size = Math.sqrt(planningUnitAreakm2) * 1000; + const gridFn = gridShapeFnMapping[planningUnitGridShape]; + const size = calculateGridSize[planningUnitGridShape]!(planningUnitAreakm2); return ` WITH region AS ( @@ -133,7 +155,9 @@ export class PlanningUnitsJobProcessor { `; } - private getGridSubquery(data: PlanningUnitsJob): string { + private getGridSubquery( + data: RegularPlanningAreaJob | CustomPlanningAreaJob, + ): string { return isCustomPlanningAreaJob(data) ? this.getCustomPlanningAreaGridSubquery(data) : this.getRegularPlanningAreaGridSubquery(data); @@ -151,7 +175,9 @@ export class PlanningUnitsJobProcessor { * * Better handle the validation, plus check why Polygon validation is not working * make sure we avoid at all costs unescaped user input */ - async process(job: Job): Promise { + async process( + job: Job, + ): Promise { this.logger.debug(`Start planning-units processing for ${job.id}...`); await this.ensureJobDataIsValid(job); @@ -160,6 +186,13 @@ export class PlanningUnitsJobProcessor { const subquery = this.getGridSubquery(job.data); await this.entityManager.transaction(async (em) => { + // since size column is of type integer we have to apply Math.round + const size = Math.round( + calculateGridSize[job.data.planningUnitGridShape]( + job.data.planningUnitAreakm2, + ), + ); + const geometries: { id: string }[] = await em.query( ` INSERT INTO planning_units_geom (the_geom, type, size) @@ -168,7 +201,7 @@ export class PlanningUnitsJobProcessor { ON CONFLICT (the_geom_hash, type) DO UPDATE SET type = $1::planning_unit_grid_shape RETURNING id `, - [job.data.planningUnitGridShape, job.data.planningUnitAreakm2], + [job.data.planningUnitGridShape, size], ); const geometryIds = geometries.map((geom) => geom.id); @@ -182,7 +215,9 @@ export class PlanningUnitsJobProcessor { geomType: job.data.planningUnitGridShape, puid: chunkIndex * chunkSize + (index + 1), projectId: job.data.projectId, - planningAreaId: job.data.planningAreaId, + planningAreaId: isCustomPlanningAreaJob(job.data) + ? job.data.planningAreaId + : undefined, })), ); }), diff --git a/api/apps/geoprocessing/src/modules/planning-units/planning-units.service.ts b/api/apps/geoprocessing/src/modules/planning-units/planning-units.service.ts index 5a2986fcfd..4a801950a6 100644 --- a/api/apps/geoprocessing/src/modules/planning-units/planning-units.service.ts +++ b/api/apps/geoprocessing/src/modules/planning-units/planning-units.service.ts @@ -1,22 +1,21 @@ -import { Injectable, Logger, Inject } from '@nestjs/common'; -import { PlanningUnitGridShape } from '@marxan/scenarios-planning-unit'; import { TileService } from '@marxan-geoprocessing/modules/tile/tile.service'; -import { IsOptional, IsString, IsArray, IsNumber } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; -import { BBox } from 'geojson'; -import { Transform } from 'class-transformer'; - import { nominatim2bbox } from '@marxan-geoprocessing/utils/bbox.utils'; +import { PlanningUnitGridShape } from '@marxan/scenarios-planning-unit'; import { TileRequest } from '@marxan/tiles'; - -enum RegularPlanningUnitGridShape { - hexagon = PlanningUnitGridShape.Hexagon, - square = PlanningUnitGridShape.Square, -} +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsArray, IsIn, IsNumber, IsOptional, IsString } from 'class-validator'; +import { BBox } from 'geojson'; +import { + calculateGridSize, + gridShapeFnMapping, + RegularPlanningUnitGridShape, +} from './planning-units.job'; export class tileSpecification extends TileRequest { @ApiProperty() - @IsString() + @IsIn([PlanningUnitGridShape.Hexagon, PlanningUnitGridShape.Square]) planningUnitGridShape!: RegularPlanningUnitGridShape; @ApiProperty() @@ -100,8 +99,10 @@ export class PlanningUnitsService { planningUnitAreakm2: number, filters?: PlanningUnitsFilters, ): string { - const gridShape = this.regularFunctionGridSelector(planningUnitGridShape); - const gridSize = this.calculateGridSize(planningUnitAreakm2); + const gridShapeFn = gridShapeFnMapping[planningUnitGridShape]; + const gridSize = calculateGridSize[planningUnitGridShape]( + planningUnitAreakm2, + ); // 156412 references to m per pixel at z level 0 at the equator in EPSG:3857 const ratioPixelExtent = gridSize / (156412 / 2 ** z); /** @@ -112,9 +113,9 @@ export class PlanningUnitsService { */ const query = ratioPixelExtent < 8 && !filters?.bbox - ? `( SELECT row_number() over() as id, st_centroid((${gridShape}(${gridSize}, \ + ? `( SELECT row_number() over() as id, st_centroid((${gridShapeFn}(${gridSize}, \ ST_Transform(ST_TileEnvelope(${z}, ${x}, ${y}), 3857))).geom ) as the_geom )` - : `( SELECT row_number() over() as id, (${gridShape}(${gridSize}, \ + : `( SELECT row_number() over() as id, (${gridShapeFn}(${gridSize}, \ ST_Transform(ST_TileEnvelope(${z}, ${x}, ${y}), 3857))).geom as the_geom)`; return query; @@ -132,29 +133,4 @@ export class PlanningUnitsService { } return whereQuery; } - - /** - * @param planningUnitGridShape the grid shape that would be use for generating the grid. This grid shape - * can be square or hexagon. If any grid shape is provided, square would be the default. - */ - private regularFunctionGridSelector( - planningUnitGridShape: RegularPlanningUnitGridShape, - ): string { - const functionEquivalence: { - [key in keyof typeof RegularPlanningUnitGridShape]: string; - } = { - hexagon: 'ST_HexagonGrid', - square: 'ST_SquareGrid', - }; - - return functionEquivalence[planningUnitGridShape]; - } - /** - * - * @param planningUnitAreakm2 - * @returns grid h size in m - */ - private calculateGridSize(planningUnitAreakm2: number): number { - return Math.sqrt(planningUnitAreakm2) * 1000; - } } diff --git a/api/apps/geoprocessing/test/e2e.config.ts b/api/apps/geoprocessing/test/e2e.config.ts index 346fd28160..c25d90bb92 100644 --- a/api/apps/geoprocessing/test/e2e.config.ts +++ b/api/apps/geoprocessing/test/e2e.config.ts @@ -4,6 +4,8 @@ import { PlanningUnitGridShape } from '@marxan/scenarios-planning-unit'; interface OptionsWithCountryCode { countryCode: string; + adminAreaLevel1Id?: string; + adminAreaLevel2Id?: string; } export const E2E_CONFIG: { @@ -25,8 +27,10 @@ export const E2E_CONFIG: { valid: { customArea: (options: OptionsWithCountryCode): PlanningUnitsJob => ({ countryId: options.countryCode, - adminAreaLevel1Id: faker.random.alphaNumeric(7), - adminAreaLevel2Id: faker.random.alphaNumeric(12), + adminAreaLevel1Id: + options.adminAreaLevel1Id ?? faker.random.alphaNumeric(7), + adminAreaLevel2Id: + options.adminAreaLevel2Id ?? faker.random.alphaNumeric(12), planningUnitGridShape: PlanningUnitGridShape.Hexagon, planningUnitAreakm2: 100, projectId: 'a9d965a2-35ce-44b2-8112-50bcdfe98447', diff --git a/api/apps/geoprocessing/test/planning-units-processor.e2e-spec.ts b/api/apps/geoprocessing/test/planning-units-processor.e2e-spec.ts index ced12d58bb..2bf75e5681 100644 --- a/api/apps/geoprocessing/test/planning-units-processor.e2e-spec.ts +++ b/api/apps/geoprocessing/test/planning-units-processor.e2e-spec.ts @@ -1,9 +1,17 @@ import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { + PlanningUnitsGeom, + ProjectsPuEntity, +} from '@marxan-jobs/planning-unit-geometry'; import { PlanningUnitsJob } from '@marxan-jobs/planning-unit-geometry/create.regular.planning-units.dto'; import { Test } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { getRepositoryToken, TypeOrmModule } from '@nestjs/typeorm'; import { Job } from 'bullmq'; -import { PlanningUnitsJobProcessor } from '../src/modules/planning-units/planning-units.job'; +import { In, Repository } from 'typeorm'; +import { + PlanningUnitsJobProcessor, + RegularPlanningAreaJob, +} from '../src/modules/planning-units/planning-units.job'; import { E2E_CONFIG } from './e2e.config'; /** @@ -12,6 +20,9 @@ import { E2E_CONFIG } from './e2e.config'; */ describe('planning units jobs (e2e)', () => { let sut: PlanningUnitsJobProcessor; + let data: PlanningUnitsJob; + let projectsPuRepo: Repository; + let planningUnitsRepo: Repository; beforeEach(async () => { const sandbox = await Test.createTestingModule({ @@ -21,26 +32,50 @@ describe('planning units jobs (e2e)', () => { keepConnectionAlive: true, logging: false, }), + TypeOrmModule.forFeature( + [ProjectsPuEntity, PlanningUnitsGeom], + geoprocessingConnections.default, + ), ], providers: [PlanningUnitsJobProcessor], }).compile(); + projectsPuRepo = sandbox.get(getRepositoryToken(ProjectsPuEntity)); + planningUnitsRepo = sandbox.get(getRepositoryToken(PlanningUnitsGeom)); sut = sandbox.get(PlanningUnitsJobProcessor); }); + afterEach(async () => { + const projectId = data.projectId; + + const projectPus = await projectsPuRepo.find({ projectId }); + const geometriesIds = projectPus.map((projectPu) => projectPu.geomId); + + await planningUnitsRepo.delete({ id: In(geometriesIds) }); + }); + it( 'executes the child job processor with mock data', async () => { + data = E2E_CONFIG.planningUnits.creationJob.valid.customArea({ + countryCode: 'NAM', + adminAreaLevel1Id: 'NAM.13_1', + adminAreaLevel2Id: 'NAM.13.5_1', + }); + const createPlanningUnitsDTO = { id: '1', name: 'create-regular-pu', - data: E2E_CONFIG.planningUnits.creationJob.valid.customArea({ - countryCode: 'NAM', - }), - } as Job; + data, + } as Job; - // TODO do actual verification & cleanup (table: planning_units_geom) after test await expect(sut.process(createPlanningUnitsDTO)).resolves.not.toThrow(); + + const projectPus = await projectsPuRepo.find({ + projectId: data.projectId, + }); + + expect(projectPus.length).toBeGreaterThan(0); }, 50 * 1000, );