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

Metrics Service #1525

Merged
merged 5 commits into from
May 24, 2024
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 @@ -114,7 +114,6 @@ describe('CreateScenariosComponent', () => {
'PlanStateService',
{
getScenario: fakeGetScenario,
getMetricData: of(null),
updateStateWithShapes: undefined,
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { ConstraintsPanelComponent } from './constraints-panel/constraints-panel
import { FeatureService } from '../../features/feature.service';
import { GoalOverlayService } from './goal-overlay/goal-overlay.service';
import { ChartData } from '../project-areas-metrics/chart-data';
import { MetricsService } from '@services/metrics.service';
import { processScenarioResultsToChartData } from '../scenario-helpers';

enum ScenarioTabs {
CONFIG,
Expand Down Expand Up @@ -74,7 +76,8 @@ export class CreateScenariosComponent implements OnInit {
private router: Router,
private matSnackBar: MatSnackBar,
private featureService: FeatureService,
private goalOverlayService: GoalOverlayService
private goalOverlayService: GoalOverlayService,
private metricsService: MetricsService
) {}

createForms() {
Expand Down Expand Up @@ -251,48 +254,20 @@ export class CreateScenariosComponent implements OnInit {
* Processes Scenario Results into ChartData format and updates PlanService State with Project Area shapes
*/
processScenarioResults(scenario: Scenario) {
let scenario_output_fields_paths =
scenario?.configuration.treatment_question?.scenario_output_fields_paths!;
let labels: string[][] = [];
let priorities =
scenario.configuration.treatment_question?.scenario_priorities;
if (scenario && this.scenarioResults) {
this.planStateService
.getMetricData(scenario_output_fields_paths)
.pipe(take(1))
.subscribe((metric_data) => {
for (let metric in metric_data) {
let displayName = metric_data[metric]['display_name'];
let dataUnits =
metric_data[metric]['output_units'] ||
metric_data[metric]['data_units'];
let metricLayer = metric_data[metric]['raw_layer'];
let metricName = metric_data[metric]['metric_name'];
let metricData: string[] = [];
if (!metric_data[metric]['hide_chart']) {
this.scenarioResults?.result.features.map((featureCollection) => {
const props = featureCollection.properties;

metricData.push(props[metric]);
});
labels.push([
displayName,
dataUnits,
metricLayer,
metricData,
metricName,
]);
}
let plan = this.plan$.getValue();
if (scenario && this.scenarioResults && plan) {
this.metricsService
.getMetricsForRegion(plan.region_name)
.subscribe((metrics) => {
if (this.scenarioResults) {
this.scenarioChartData = processScenarioResultsToChartData(
metrics,
scenario.configuration,
this.scenarioResults
);
}
this.scenarioChartData = labels.map((label, _) => ({
label: label[0],
measurement: label[1],
metric_layer: label[2],
values: label[3] as unknown as number[],
key: label[4],
is_primary: priorities?.includes(label[4]) || false,
}));
});

this.planStateService.updateStateWithShapes(
this.scenarioResults?.result.features
);
Expand Down
156 changes: 156 additions & 0 deletions src/interface/src/app/plan/scenario-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { MetricConfig, ScenarioConfig, ScenarioResult } from '@types';
import { ChartData } from './project-areas-metrics/chart-data';
import { processScenarioResultsToChartData } from './scenario-helpers';

describe('processScenarioResultsToChartData', () => {
const metrics: MetricConfig[] = [
{
metric_name: 'metric1',
display_name: 'Metric 1',
data_units: 'units',
raw_layer: 'layer1',
},
{
metric_name: 'metric2',
display_name: 'Metric 2',
data_units: 'units',
raw_layer: 'layer2',
},
{
metric_name: 'metric3',
display_name: 'Metric 3',
data_units: 'units',
raw_layer: 'layer3',
},
];

const scenarioConfig: ScenarioConfig = {
scenario_priorities: ['metric1'],
scenario_output_fields: ['metric2', 'metric3'],
};

const scenarioResults: ScenarioResult = {
status: 'PENDING',
completed_at: '0',
result: {
type: 'Feature',
features: [
{
properties: { metric1: 10, metric2: 20, metric3: 30 },
type: 'FeatureCollection',
features: [],
},
{
properties: { metric1: 15, metric2: 25, metric3: 35 },
type: 'FeatureCollection',
features: [],
},
{
properties: { metric1: 20, metric2: 30, metric3: 40 },
type: 'FeatureCollection',
features: [],
},
],
},
};

it('should process metrics and return ChartData correctly', () => {
const expectedOutput: ChartData[] = [
{
label: 'Metric 1',
measurement: 'units',
key: 'metric1',
values: [10, 15, 20],
metric_layer: 'layer1',
is_primary: true,
},
{
label: 'Metric 2',
measurement: 'units',
key: 'metric2',
values: [20, 25, 30],
metric_layer: 'layer2',
is_primary: false,
},
{
label: 'Metric 3',
measurement: 'units',
key: 'metric3',
values: [30, 35, 40],
metric_layer: 'layer3',
is_primary: false,
},
];

const result = processScenarioResultsToChartData(
metrics,
scenarioConfig,
scenarioResults
);
expect(result).toEqual(expectedOutput);
});

it('should handle empty metrics array', () => {
const emptyMetrics: MetricConfig[] = [];
const result = processScenarioResultsToChartData(
emptyMetrics,
scenarioConfig,
scenarioResults
);
expect(result).toEqual([]);
});

it('should handle empty scenario results', () => {
const emptyScenarioResults: ScenarioResult = {
status: 'PENDING',
completed_at: '0',
result: {
features: [],
type: '',
},
};
const expectedOutput: ChartData[] = [
{
label: 'Metric 1',
measurement: 'units',
key: 'metric1',
values: [],
metric_layer: 'layer1',
is_primary: true,
},
{
label: 'Metric 2',
measurement: 'units',
key: 'metric2',
values: [],
metric_layer: 'layer2',
is_primary: false,
},
{
label: 'Metric 3',
measurement: 'units',
key: 'metric3',
values: [],
metric_layer: 'layer3',
is_primary: false,
},
];

const result = processScenarioResultsToChartData(
metrics,
scenarioConfig,
emptyScenarioResults
);
expect(result).toEqual(expectedOutput);
});

it('should handle missing priorities and output fields', () => {
const emptyConfig: ScenarioConfig = {};
const result = processScenarioResultsToChartData(
metrics,
emptyConfig,
scenarioResults
);
expect(result).toEqual([]);
});
});
32 changes: 32 additions & 0 deletions src/interface/src/app/plan/scenario-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { MetricConfig, ScenarioConfig, ScenarioResult } from '@types';
import { ChartData } from './project-areas-metrics/chart-data';

export function processScenarioResultsToChartData(
metrics: MetricConfig[],
scenarioConfig: ScenarioConfig,
scenarioResults: ScenarioResult
): ChartData[] {
const {
scenario_priorities: priorities = [],
scenario_output_fields: secondary = [],
} = scenarioConfig;
const outputFields = new Set([...secondary, ...priorities]);

return metrics.reduce((acc: ChartData[], metric) => {
if (outputFields.has(metric.metric_name)) {
const metricData = scenarioResults.result.features.map(
(featureCollection) => featureCollection.properties[metric.metric_name]
);

acc.push({
label: metric.display_name,
measurement: metric.data_units,
key: metric.metric_name,
values: metricData,
metric_layer: metric.raw_layer,
is_primary: priorities.includes(metric.metric_name),
} as ChartData);
}
return acc;
}, []);
}
67 changes: 67 additions & 0 deletions src/interface/src/app/services/metrics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { MetricsService } from './metrics.service';
import { MetricConfig, Region, regionToString } from '@types';
import { environment } from '../../environments/environment';

describe('MetricsService', () => {
let service: MetricsService;
let httpMock: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [MetricsService],
});

service = TestBed.inject(MetricsService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
httpMock.verify();
});

it('should return cached conditions if they exist', () => {
const mockConditions: MetricConfig[] = [];
service['conditions'][Region.CENTRAL_COAST] = mockConditions;

service
.getMetricsForRegion(Region.CENTRAL_COAST)
.subscribe((conditions) => {
expect(conditions).toEqual(mockConditions);
});

// Ensure no HTTP requests are made
httpMock.expectNone(
environment.backend_endpoint +
'/conditions/config/?flat=true&region_name=' +
regionToString(Region.CENTRAL_COAST)
);
});

it('should fetch conditions from the backend if not cached', () => {
const mockConditions: MetricConfig[] = [];
service['conditions'][Region.CENTRAL_COAST] = null;

service
.getMetricsForRegion(Region.CENTRAL_COAST)
.subscribe((conditions) => {
expect(conditions).toEqual(mockConditions);
expect(service['conditions'][Region.CENTRAL_COAST]).toEqual(
mockConditions
);
});

const req = httpMock.expectOne(
environment.backend_endpoint +
'/conditions/config/?flat=true&region_name=' +
regionToString(Region.CENTRAL_COAST)
);
expect(req.request.method).toBe('GET');
req.flush(mockConditions);
});
});
40 changes: 40 additions & 0 deletions src/interface/src/app/services/metrics.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { MetricConfig, Region, regionToString } from '@types';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
import { of, tap } from 'rxjs';

@Injectable({
providedIn: 'root',
})
export class MetricsService {
/**
* Stores a record of metrics for each region.
*/
private conditions: Record<Region, MetricConfig[] | null> = {
[Region.CENTRAL_COAST]: null,
[Region.NORTHERN_CALIFORNIA]: null,
[Region.SIERRA_NEVADA]: null,
[Region.SOUTHERN_CALIFORNIA]: null,
};

constructor(private http: HttpClient) {}

/**
* Gets a flat list of metrics for a given region.
* Only fetches the metrics if we dont have a record already
* @param region
*/
public getMetricsForRegion(region: Region) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

if (this.conditions[region] !== null) {
return of(this.conditions[region] as MetricConfig[]);
}
return this.http
.get<
MetricConfig[]
>(environment.backend_endpoint + '/conditions/config/?flat=true&region_name=' + `${regionToString(region)}`)
.pipe(
tap((conditionsConfig) => (this.conditions[region] = conditionsConfig))
);
}
}
Loading
Loading