diff --git a/api/apps/api/src/modules/scenarios/dto/scenario-solution-result.dto.ts b/api/apps/api/src/modules/scenarios/dto/scenario-solution-result.dto.ts new file mode 100644 index 0000000000..4e83bd619f --- /dev/null +++ b/api/apps/api/src/modules/scenarios/dto/scenario-solution-result.dto.ts @@ -0,0 +1,46 @@ +import { + ApiExtraModels, + ApiProperty, + getSchemaPath, + refs, +} from '@nestjs/swagger'; +import { ScenariosOutputResultsGeoEntity } from '@marxan/scenarios-planning-unit'; +import { oneOf } from 'purify-ts'; + +class ScenarioSolutionsDataDto { + @ApiProperty() + type: 'solutions' = 'solutions'; + + @ApiProperty() + id!: string; + + @ApiProperty({ + isArray: true, + type: () => ScenariosOutputResultsGeoEntity, + }) + attributes!: ScenariosOutputResultsGeoEntity[]; +} + +class ScenarioSolutionDataDto { + @ApiProperty() + type: 'solution' = 'solution'; + + @ApiProperty() + id!: string; + + @ApiProperty({ + type: () => ScenariosOutputResultsGeoEntity, + }) + attributes!: ScenariosOutputResultsGeoEntity; +} + +@ApiExtraModels(ScenarioSolutionsDataDto, ScenarioSolutionDataDto) +export class ScenarioSolutionResultDto { + @ApiProperty({ + oneOf: [ + { $ref: getSchemaPath(ScenarioSolutionsDataDto) }, + { $ref: getSchemaPath(ScenarioSolutionDataDto) }, + ], + }) + data!: ScenarioSolutionsDataDto | ScenarioSolutionDataDto; +} diff --git a/api/apps/api/src/modules/scenarios/dto/scenario-solution.serializer.ts b/api/apps/api/src/modules/scenarios/dto/scenario-solution.serializer.ts new file mode 100644 index 0000000000..e2d8ebce15 --- /dev/null +++ b/api/apps/api/src/modules/scenarios/dto/scenario-solution.serializer.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { PaginationMeta } from '@marxan-api/utils/app-base.service'; +import { ScenariosOutputResultsGeoEntity } from '@marxan/scenarios-planning-unit'; +import { SolutionResultCrudService } from '../solutions-result/solution-result-crud.service'; + +@Injectable() +export class ScenarioSolutionSerializer { + constructor( + private readonly scenariosSolutionsCrudService: SolutionResultCrudService, + ) {} + + async serialize( + entities: + | Partial + | (Partial | undefined)[], + paginationMeta?: PaginationMeta, + ): Promise { + return this.scenariosSolutionsCrudService.serialize( + entities, + paginationMeta, + ); + } +} diff --git a/api/apps/api/src/modules/scenarios/scenarios.controller.ts b/api/apps/api/src/modules/scenarios/scenarios.controller.ts index 4b42bcd026..c2662b9a8a 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.controller.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.controller.ts @@ -4,9 +4,11 @@ import { Delete, Get, Param, + ParseBoolPipe, ParseUUIDPipe, Patch, Post, + Query, Req, UploadedFile, UseGuards, @@ -47,6 +49,10 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { ScenariosService } from './scenarios.service'; import { ScenarioSerializer } from './dto/scenario.serializer'; import { ScenarioFeatureSerializer } from './dto/scenario-feature.serializer'; +import { ScenarioFeatureResultDto } from './dto/scenario-feature-result.dto'; +import { ScenarioSolutionResultDto } from './dto/scenario-solution-result.dto'; +import { ApiImplicitQuery } from '@nestjs/swagger/dist/decorators/api-implicit-query.decorator'; +import { ScenarioSolutionSerializer } from './dto/scenario-solution.serializer'; @UseGuards(JwtAuthGuard) @ApiBearerAuth() @@ -57,6 +63,7 @@ export class ScenariosController { private readonly service: ScenariosService, private readonly scenarioSerializer: ScenarioSerializer, private readonly scenarioFeatureSerializer: ScenarioFeatureSerializer, + private readonly scenarioSolutionSerializer: ScenarioSolutionSerializer, ) {} @ApiOperation({ @@ -170,4 +177,53 @@ export class ScenariosController { await this.service.getFeatures(id), ); } + + @ApiOkResponse({ + type: ScenarioSolutionResultDto, + }) + @ApiImplicitQuery({ + name: 'best', + required: false, + type: Boolean, + }) + @ApiImplicitQuery({ + name: 'most-different', + required: false, + type: Boolean, + }) + @JSONAPIQueryParams() + @Get(`:id/marxan/run/:runId/solutions`) + async getScenarioRunSolutions( + @Param('id', ParseUUIDPipe) id: string, + @Param('runId', ParseUUIDPipe) runId: string, + @ProcessFetchSpecification() fetchSpecification: FetchSpecification, + @Query('best', ParseBoolPipe) selectOnlyBest?: boolean, + @Query('most-different', ParseBoolPipe) selectMostDifferent?: boolean, + ): Promise { + if (selectOnlyBest) { + return this.scenarioSolutionSerializer.serialize( + await this.service.getBestSolution(id, runId), + ); + } + if (selectMostDifferent) { + const result = await this.service.getMostDifferentSolutions( + id, + runId, + fetchSpecification, + ); + return this.scenarioSolutionSerializer.serialize( + result.data, + result.metadata, + ); + } + const result = await this.service.findAllSolutionsPaginated( + id, + runId, + fetchSpecification, + ); + return this.scenarioSolutionSerializer.serialize( + result.data, + result.metadata, + ); + } } diff --git a/api/apps/api/src/modules/scenarios/scenarios.module.ts b/api/apps/api/src/modules/scenarios/scenarios.module.ts index a6600e3064..aa33ff3f79 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.module.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.module.ts @@ -18,6 +18,10 @@ import { ScenariosService } from './scenarios.service'; import { ScenarioSerializer } from './dto/scenario.serializer'; import { ScenarioFeatureSerializer } from './dto/scenario-feature.serializer'; import { CostSurfaceTemplateModule } from './cost-surface-template'; +import { SolutionResultCrudService } from './solutions-result/solution-result-crud.service'; +import { DbConnections } from '@marxan-api/ormconfig.connections'; +import { ScenariosOutputResultsGeoEntity } from '@marxan/scenarios-planning-unit'; +import { ScenarioSolutionSerializer } from './dto/scenario-solution.serializer'; @Module({ imports: [ @@ -25,6 +29,10 @@ import { CostSurfaceTemplateModule } from './cost-surface-template'; ProtectedAreasModule, forwardRef(() => ProjectsModule), TypeOrmModule.forFeature([Project, Scenario]), + TypeOrmModule.forFeature( + [ScenariosOutputResultsGeoEntity], + DbConnections.geoprocessingDB, + ), UsersModule, ScenarioFeaturesModule, AnalysisModule, @@ -39,6 +47,8 @@ import { CostSurfaceTemplateModule } from './cost-surface-template'; WdpaAreaCalculationService, ScenarioSerializer, ScenarioFeatureSerializer, + SolutionResultCrudService, + ScenarioSolutionSerializer, ], controllers: [ScenariosController], exports: [ScenariosCrudService, ScenariosService], diff --git a/api/apps/api/src/modules/scenarios/scenarios.service.ts b/api/apps/api/src/modules/scenarios/scenarios.service.ts index 3044933d1e..91ff9c67f5 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.service.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.service.ts @@ -13,6 +13,7 @@ import { ScenariosCrudService } from './scenarios-crud.service'; import { CreateScenarioDTO } from './dto/create.scenario.dto'; import { UpdateScenarioDTO } from './dto/update.scenario.dto'; import { UpdateScenarioPlanningUnitLockStatusDto } from './dto/update-scenario-planning-unit-lock-status.dto'; +import { SolutionResultCrudService } from './solutions-result/solution-result-crud.service'; @Injectable() export class ScenariosService { @@ -26,6 +27,7 @@ export class ScenariosService { private readonly updatePlanningUnits: AdjustPlanningUnits, private readonly costSurface: CostSurfaceFacade, private readonly httpService: HttpService, + private readonly solutionsCrudService: SolutionResultCrudService, ) {} async findAllPaginated(fetchSpecification: FetchSpecification) { @@ -103,7 +105,42 @@ export class ScenariosService { return geoJson; } + async findScenarioResults( + scenarioId: string, + runId: string, + fetchSpecification: FetchSpecification, + ) { + await this.assertScenario(scenarioId); + return this.solutionsCrudService.findAll(fetchSpecification); + } + private async assertScenario(scenarioId: string) { await this.crudService.getById(scenarioId); } + + async getBestSolution(scenarioId: string, runId: string) { + await this.assertScenario(scenarioId); + // TODO correct implementation + return this.solutionsCrudService.getById(runId); + } + + async getMostDifferentSolutions( + scenarioId: string, + runId: string, + fetchSpecification: FetchSpecification, + ) { + await this.assertScenario(scenarioId); + // TODO correct implementation + return this.solutionsCrudService.findAllPaginated(fetchSpecification); + } + + async findAllSolutionsPaginated( + scenarioId: string, + runId: string, + fetchSpecification: FetchSpecification, + ) { + await this.assertScenario(scenarioId); + // TODO correct implementation + return this.solutionsCrudService.findAllPaginated(fetchSpecification); + } } diff --git a/api/apps/api/src/modules/scenarios/solutions-result/solution-result-crud.service.ts b/api/apps/api/src/modules/scenarios/solutions-result/solution-result-crud.service.ts new file mode 100644 index 0000000000..71eaaa2c85 --- /dev/null +++ b/api/apps/api/src/modules/scenarios/solutions-result/solution-result-crud.service.ts @@ -0,0 +1,75 @@ +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + AppBaseService, + JSONAPISerializerConfig, +} from '@marxan-api/utils/app-base.service'; + +import { AppInfoDTO } from '@marxan-api/dto/info.dto'; +import { AppConfig } from '@marxan-api/utils/config.utils'; +import { FetchSpecification } from 'nestjs-base-service'; +import { ScenariosOutputResultsGeoEntity } from '@marxan/scenarios-planning-unit'; +import { DbConnections } from '@marxan-api/ormconfig.connections'; + +@Injectable() +export class SolutionResultCrudService extends AppBaseService< + ScenariosOutputResultsGeoEntity, + never, + never, + AppInfoDTO +> { + constructor( + @InjectRepository( + ScenariosOutputResultsGeoEntity, + DbConnections.geoprocessingDB, + ) + protected readonly repository: Repository, + ) { + super(repository, 'solution', 'solutions', { + logging: { muteAll: AppConfig.get('logging.muteAll', false) }, + }); + } + + get serializerConfig(): JSONAPISerializerConfig { + return { + attributes: [ + 'id', + 'planningUnits', + 'missingValues', + 'cost', + 'score', + 'run', + ], + keyForAttribute: 'camelCase', + }; + } + + async extendFindAllResults( + entitiesAndCount: [ScenariosOutputResultsGeoEntity[], number], + _fetchSpecification?: FetchSpecification, + _info?: AppInfoDTO, + ): Promise<[ScenariosOutputResultsGeoEntity[], number]> { + const extendedEntities: Promise[] = entitiesAndCount[0].map( + (entity) => this.extendGetByIdResult(entity), + ); + return [await Promise.all(extendedEntities), entitiesAndCount[1]]; + } + + async extendGetByIdResult( + entity: ScenariosOutputResultsGeoEntity, + _fetchSpecification?: FetchSpecification, + _info?: AppInfoDTO, + ): Promise { + entity.planningUnits = 17; + entity.missingValues = 13; + entity.cost = 400; + entity.score = 999; + entity.run = 1; + return entity; + } + + // TODO DTO (x2) + // TODO params switcher + // TODO serializer +} diff --git a/api/libs/scenarios-planning-unit/src/index.ts b/api/libs/scenarios-planning-unit/src/index.ts index f44a119ae4..e25696aa62 100644 --- a/api/libs/scenarios-planning-unit/src/index.ts +++ b/api/libs/scenarios-planning-unit/src/index.ts @@ -1,3 +1,4 @@ export { LockStatus } from './lock-status.enum'; export { ScenariosPlanningUnitGeoEntity } from './scenarios-planning-unit.geo.entity'; +export { ScenariosOutputResultsGeoEntity } from './scenarios-output-results.geo.entity'; export * from './domain'; diff --git a/api/libs/scenarios-planning-unit/src/scenarios-output-results.geo.entity.ts b/api/libs/scenarios-planning-unit/src/scenarios-output-results.geo.entity.ts new file mode 100644 index 0000000000..d120a13cda --- /dev/null +++ b/api/libs/scenarios-planning-unit/src/scenarios-output-results.geo.entity.ts @@ -0,0 +1,90 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; + +const tableName = `output_results_data`; + +@Entity(tableName) +export class ScenariosOutputResultsGeoEntity { + @ApiProperty() + @PrimaryGeneratedColumn('uuid') + id!: string; + + /** + * references ScenariosPlanningUnitGeoEntity.planningUnitMarxanId + */ + @Column({ + type: 'int', + nullable: true, + name: `puid`, + }) + scenariosPuDataPlanningUnitMarxanId?: number | null; + + @Column({ + type: `uuid`, + nullable: true, + name: `scenario_id`, + }) + scenarioId?: string | null; + + /** + * TODO describe/change + */ + @Column({ + type: `uuid`, + nullable: true, + name: `run_id`, + }) + runId?: string | null; + + /** + * Score of the run + */ + @Column({ + type: `float8`, + nullable: true, + name: `value`, + }) + scoreValue?: number | null; + + /** + * TODO describe/change + */ + @Column({ + type: `jsonb`, + nullable: true, + name: `missing_values`, + }) + missingValuesJsonb?: unknown | null; + + /** + * API fields + * -------------------- + */ + + @ApiProperty({ + description: `The number of planning units contained in the solution for that run.`, + }) + planningUnits!: number; + + @ApiProperty({ + description: `The number of planning units omitted in the solution for that run.`, + }) + missingValues!: number; + + // TODO describe + @ApiProperty({ + description: `TODO`, + }) + cost!: number; + + @ApiProperty({ + description: `Score value of the solution - the higher, the better.`, + }) + score!: number; + + // TODO describe + @ApiProperty({ + description: ``, + }) + run!: number; +}