Skip to content

Commit

Permalink
Add overlap report create:report (#271)
Browse files Browse the repository at this point in the history
* Adds overlapRasterGroupMetrics function

* Adds createReport functionality

* Type nit

* Allows report creation for unused metric groups only

* Begin support for overlap catagorical raster reports

* Supports creation of categorical raster overlap report

* Supports objectives in create:report reports

* Add rasterMetrics support for creating all categorical raster metrics

* Return all histogram metrics when categoryClassValues is empty

* nit

* Updates rasterMetrics to include categoryMetricProperty

* Add to CLI docs

* Adds categoryMetricProperty

* Fix metricId override

* Reports histogram stats as valid

* nit

* Update error message when no metric groups to report on

* Documentation update

* Docs update
  • Loading branch information
avmey authored Mar 18, 2024
1 parent d73e2d0 commit c73cf58
Show file tree
Hide file tree
Showing 25 changed files with 2,545 additions and 1,105 deletions.
1 change: 1 addition & 0 deletions packages/base-project/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"translation:sync": "npm run translation:extract && npm run translation:publish && npm run translation:import",
"create:function": "geoprocessing create:function",
"create:client": "geoprocessing create:client",
"create:report": "geoprocessing create:report",
"start:client": "geoprocessing start:client",
"synth": "geoprocessing synth",
"bootstrap": "geoprocessing bootstrap",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from "../../../src/types";
import {
createMetric,
getHistogram,
bboxOverlap,
BBox,
ProjectClientBase,
Expand Down Expand Up @@ -147,21 +146,17 @@ export async function precalcRasterMetrics(

// Creates metrics for categorical raster (histogram, count valid cells by class)
if (datasource.measurementType === "categorical") {
const metrics: Metric[] = [];
const histogram = (await getHistogram(raster)) as Histogram;
if (!histogram) throw new Error("Histogram not returned");

Object.keys(histogram).forEach((curClass) => {
metrics.push(
createMetric({
geographyId: geography.geographyId,
classId: datasource.datasourceId + "-" + curClass,
metricId: "valid",
value: histogram[curClass],
})
);
});

const metrics = (
await rasterMetrics(raster, {
feature: geographyFeatureColl,
includeChildMetrics: false,
categorical: true,
})
).map((m) => ({
...m,
geographyId: geography.geographyId,
classId: datasource.datasourceId + "-" + m.classId,
}));
return metrics;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/geoprocessing/scripts/geoprocessing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ if (process.argv.length < 3) {
stdio: "inherit",
});
break;
case "create:report":
spawn("node", [`${__dirname}/init/createReport.js`], {
cwd: process.cwd(),
stdio: "inherit",
});
break;
case "build:lambda":
spawn(`${__dirname}/../../scripts/build/build.sh`, {
cwd: process.cwd(),
Expand Down
315 changes: 315 additions & 0 deletions packages/geoprocessing/scripts/init/createReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
import inquirer from "inquirer";
import ora from "ora";
import fs from "fs-extra";
import path from "path";
import chalk from "chalk";
import camelcase from "camelcase";
import {
ExecutionMode,
metricGroupsSchema,
GeoprocessingJsonConfig,
} from "../../src/types";
import {
getBlankComponentPath,
getBlankFunctionPath,
getOceanEEZComponentPath,
getOceanEEZFunctionPath,
getProjectComponentPath,
getProjectConfigPath,
getProjectFunctionPath,
} from "../util/getPaths";

// CLI questions
const createReport = async () => {
// Report type, description, and execution mode
const answers = await inquirer.prompt([
{
type: "list",
name: "type",
message: "Type of report to create",
choices: [
{
value: "blank",
name: "Blank report",
},
{
value: "raster",
name: "Raster overlap report - Calculates sketch overlap with raster data sources",
},
{
value: "vector",
name: "Vector overlap report - Calculates sketch overlap with vector data sources",
},
],
},
{
type: "input",
name: "description",
message: "Describe what this report calculates",
},
{
type: "list",
name: "executionMode",
message: "Choose an execution mode for this report",
choices: [
{
value: "sync",
name: "Sync - Best for quick analyses (< 2s)",
},
{
value: "async",
name: "Async - Better for long-running processes",
},
],
},
]);

// Title of report
if (answers.type === "raster" || answers.type === "vector") {
// For raster and vector overlap reports, we need to know which metric group to report on
const rawMetrics = fs.readJSONSync(
`${getProjectConfigPath("")}/metrics.json`
);
const metrics = metricGroupsSchema.parse(rawMetrics);
const geoprocessingJson = JSON.parse(
fs.readFileSync("./geoprocessing.json").toString()
) as GeoprocessingJsonConfig;
const gpFunctions = geoprocessingJson.geoprocessingFunctions || [];
const availableMetricGroups = metrics
.map((metric) => metric.metricId)
.filter(
(metricId) => !gpFunctions.includes(`src/functions/${metricId}.ts`)
);
if (!availableMetricGroups.length)
throw new Error(
"All existing metric groups have reports. Either create a new metric group or delete an existing report, then try again."
);

// Only allow creation of reports for unused metric groups (prevents overwriting)
const titleChoiceQuestion = {
type: "list",
name: "title",
message: "Select the metric group to report on",
choices: availableMetricGroups,
};
const { title } = await inquirer.prompt([titleChoiceQuestion]);
answers.title = title;
} else {
// User inputs title
const titleQuestion = {
type: "input",
name: "title",
message: "Title for this report, in camelCase",
default: "newReport",
validate: (value: any) =>
/^\w+$/.test(value) ? true : "Please use only alphabetical characters",
transformer: (value: any) => camelcase(value),
};
const { title } = await inquirer.prompt([titleQuestion]);
answers.title = title;
}

// Stat to calculate
if (answers.type === "raster") {
const measurementTypeQuestion = {
type: "list",
name: "measurementType",
message: "Type of raster data",
choices: [
{
value: "quantitative",
name: "Quantitative - Continuous variable across the raster",
},
{
value: "categorical",
name: "Categorical - Discrete values representing different classes",
},
],
};
const { measurementType } = await inquirer.prompt([
measurementTypeQuestion,
]);
answers.measurementType = measurementType;

if (answers.measurementType === "quantitative") {
const statQuestion = {
type: "list",
name: "stat",
message: "Statistic to calculate",
choices: ["sum", "count", "area"],
};
const { stat } = await inquirer.prompt([statQuestion]);
answers.stat = stat;
} else {
answers.stat = "valid";
}
} else if (answers.type === "vector") {
// For vector overlap reports, use area stat
answers.stat = "area";
}

return answers;
};

if (require.main === module) {
createReport()
.then(async (answers) => {
await makeReport(answers, true, "");
})
.catch((error) => {
console.error("Error occurred:", error);
});
}

export async function makeReport(
options: ReportOptions,
interactive = true,
basePath = "./"
) {
// Start interactive spinner
const spinner = interactive
? ora("Creating new report").start()
: { start: () => false, stop: () => false, succeed: () => false };
spinner.start(`creating handler from templates`);

// Get paths
const projectFunctionPath = getProjectFunctionPath(basePath);
const projectComponentPath = getProjectComponentPath(basePath);

const templateFuncPath =
options.type === "blank"
? getBlankFunctionPath()
: getOceanEEZFunctionPath();
const templateFuncTestPath = `${getBlankFunctionPath()}/blankFunctionSmoke.test.ts`;
const templateCompPath =
options.type === "blank"
? getBlankComponentPath()
: getOceanEEZComponentPath();
const templateCompStoriesPath = `${getBlankComponentPath()}/BlankCard.stories.tsx`;

if (!fs.existsSync(path.join(basePath, "src"))) {
fs.mkdirSync(path.join(basePath, "src"));
}
if (!fs.existsSync(path.join(basePath, "src", "functions"))) {
fs.mkdirSync(path.join(basePath, "src", "functions"));
}
if (!fs.existsSync(path.join(basePath, "src", "components"))) {
fs.mkdirSync(path.join(basePath, "src", "components"));
}

// Get defaults to replace
const defaultFuncName =
options.type === "raster"
? "rasterFunction"
: options.type === "vector"
? "vectorFunction"
: "blankFunction";
const defaultFuncRegex =
options.type === "raster"
? /rasterFunction/g
: options.type === "vector"
? /vectorFunction/g
: /blankFunction/g;
const blankFuncRegex = /blankFunction/g;
const defaultCompName =
options.type === "raster" || options.type === "vector"
? "OverlapCard"
: "BlankCard";
const defaultCompRegex =
options.type === "raster" || options.type === "vector"
? /OverlapCard/g
: /BlankCard/g;
const blankCompRegex = /BlankCard/g;

// Load code templates
const funcCode = await fs.readFile(
`${templateFuncPath}/${defaultFuncName}.ts`
);
const testFuncCode = await fs.readFile(templateFuncTestPath);
const componentCode = await fs.readFile(
`${templateCompPath}/${defaultCompName}.tsx`
);
const storiesComponentCode = await fs.readFile(templateCompStoriesPath);

// User inputs to replace defaults
const funcName = options.title;
const compName = funcName.charAt(0).toUpperCase() + funcName.slice(1);

// Write function file
await fs.writeFile(
`${projectFunctionPath}/${funcName}.ts`,
funcCode
.toString()
.replace(defaultFuncRegex, funcName)
.replace(`"async"`, `"${options.executionMode}"`)
.replace("Function description", options.description)
.replace(`stats: ["sum"]`, `stats: ["${options.stat}"]`) // for raster
);

// Write function smoke test file
await fs.writeFile(
`${projectFunctionPath}/${funcName}Smoke.test.ts`,
testFuncCode.toString().replace(blankFuncRegex, funcName)
);

// Write component file
await fs.writeFile(
`${projectComponentPath}/${compName}.tsx`,
componentCode
.toString()
.replace(defaultCompRegex, `${compName}`)
.replace(defaultFuncRegex, `${funcName}`)
.replace(/overlapFunction/g, `${funcName}`)
.replace(`"sum"`, `"${options.stat}"`) // for raster/vector overlap reports
);

// Write component stories file
await fs.writeFile(
`${projectComponentPath}/${compName}.stories.tsx`,
storiesComponentCode
.toString()
.replace(blankCompRegex, `${compName}`)
.replace(blankFuncRegex, `${funcName}`)
);

// Add function to geoprocessing.json
const geoprocessingJson = JSON.parse(
fs.readFileSync(path.join(basePath, "geoprocessing.json")).toString()
) as GeoprocessingJsonConfig;
geoprocessingJson.geoprocessingFunctions =
geoprocessingJson.geoprocessingFunctions || [];
geoprocessingJson.geoprocessingFunctions.push(
`src/functions/${options.title}.ts`
);
fs.writeFileSync(
path.join(basePath, "geoprocessing.json"),
JSON.stringify(geoprocessingJson, null, " ")
);

// Finish and show next steps
spinner.succeed(`Created ${options.title} report`);
if (interactive) {
console.log(chalk.blue(`\nReport successfully created!`));
console.log(
chalk.blue(`Function: ${`${projectFunctionPath}/${funcName}.ts`}`)
);
console.log(
chalk.blue(`Component: ${`${projectComponentPath}/${compName}.tsx`}`)
);
console.log(`\nNext Steps:
* Add your new <${compName} /> component to your reports by adding it to Viability.tsx or Representation.tsx
* Run 'npm test' to run smoke tests against your new function
* View your report using 'npm start-storybook' with smoke test output
`);
}
}

export { createReport };

interface ReportOptions {
type: string;
stat?: string;
title: string;
executionMode: ExecutionMode;
description: string;
}
Loading

0 comments on commit c73cf58

Please sign in to comment.