Skip to content

Commit

Permalink
Merge pull request #1045 from Vizzuality/feat/MARXAN-1529-fix-regular…
Browse files Browse the repository at this point in the history
…-planning-units-size-calculation

fix: size calculation for hexagon grids
  • Loading branch information
angelhigueraacid authored May 9, 2022
2 parents 83049a3 + 0db1dd4 commit c7f408a
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,44 @@ type CustomPlanningAreaJob = Required<
PlanningUnitsJob,
'countryId' | 'adminRegionId' | 'adminAreaLevel1Id' | 'adminAreaLevel2Id'
>
>;
type RegularPlanningAreaJob = Omit<PlanningUnitsJob, 'planningAreaId'>;
> & { planningUnitGridShape: RegularPlanningUnitGridShape };

export type RegularPlanningAreaJob = Omit<
PlanningUnitsJob,
'planningAreaId'
> & {
planningUnitGridShape: RegularPlanningUnitGridShape;
};

export type RegularPlanningUnitGridShape =
| PlanningUnitGridShape.Hexagon
| PlanningUnitGridShape.Square;

function isCustomPlanningAreaJob(
job: PlanningUnitsJob,
): job is CustomPlanningAreaJob {
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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
Expand All @@ -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);
Expand All @@ -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<PlanningUnitsJob>): Promise<void> {
async process(
job: Job<RegularPlanningAreaJob | CustomPlanningAreaJob>,
): Promise<void> {
this.logger.debug(`Start planning-units processing for ${job.id}...`);

await this.ensureJobDataIsValid(job);
Expand All @@ -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)
Expand All @@ -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);

Expand All @@ -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,
})),
);
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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);
/**
Expand All @@ -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;
Expand All @@ -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;
}
}
8 changes: 6 additions & 2 deletions api/apps/geoprocessing/test/e2e.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { PlanningUnitGridShape } from '@marxan/scenarios-planning-unit';

interface OptionsWithCountryCode {
countryCode: string;
adminAreaLevel1Id?: string;
adminAreaLevel2Id?: string;
}

export const E2E_CONFIG: {
Expand All @@ -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',
Expand Down
49 changes: 42 additions & 7 deletions api/apps/geoprocessing/test/planning-units-processor.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -12,6 +20,9 @@ import { E2E_CONFIG } from './e2e.config';
*/
describe('planning units jobs (e2e)', () => {
let sut: PlanningUnitsJobProcessor;
let data: PlanningUnitsJob;
let projectsPuRepo: Repository<ProjectsPuEntity>;
let planningUnitsRepo: Repository<PlanningUnitsGeom>;

beforeEach(async () => {
const sandbox = await Test.createTestingModule({
Expand All @@ -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<PlanningUnitsJob>;
data,
} as Job<RegularPlanningAreaJob>;

// 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,
);
Expand Down

0 comments on commit c7f408a

Please sign in to comment.