Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: size calculation for hexagon grids #1045

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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