Skip to content
Draft
18 changes: 13 additions & 5 deletions lib/database/repositories/QcFlagRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ class QcFlagRepository extends Repository {
* and informtion about missing and unverified flags
*
* @param {number} dataPassId the id of a data-pass
* @param {number} [runNumber] run number to filter by
* @return {Promise<Object.<number, RunGaqSubSummary>>} resolves with the map between run number and the corresponding run GAQ summary
*/
async getGaqCoverages(dataPassId) {
async getGaqCoverages(dataPassId, runNumber) {
const blockAggregationQuery = `
SELECT
gaq_periods.data_pass_id,
Expand Down Expand Up @@ -147,7 +148,8 @@ class QcFlagRepository extends Repository {
AND (qcfep.to IS NULL OR qcfep.\`to\` > gaq_periods.\`from\`)

WHERE gaq_periods.data_pass_id = :dataPassId
GROUP BY gaq_periods.data_pass_id, gaq_periods.run_number, gaq_periods.\`from\`, gaq_periods.to
${runNumber !== undefined && runNumber !== null ? 'AND gaq_periods.run_number = :runNumber' : ''}
GROUP BY gaq_periods.data_pass_id, gaq_periods.run_number, gaq_periods.\`from\`, gaq_periods.\`to\`
`;

const summaryQuery = `
Expand All @@ -164,14 +166,20 @@ class QcFlagRepository extends Repository {
FROM (${blockAggregationQuery}) AS gaq
GROUP BY gaq.data_pass_id, gaq.run_number;
`;
const [rows] = await this.model.sequelize.query(summaryQuery, { replacements: { dataPassId } });

const replacements = { dataPassId };
if (runNumber !== undefined && runNumber !== null) {
replacements.runNumber = runNumber;
}

const [rows] = await this.model.sequelize.query(summaryQuery, { replacements });
const entries = rows.map(({
run_number,
bad_coverage,
mcr_coverage,
good_coverage,
flags_list,
verifiedd_flags_list,
verified_flags_list,
undefined_quality_periods_count,
}) => [
run_number,
Expand All @@ -180,7 +188,7 @@ class QcFlagRepository extends Repository {
mcReproducibleCoverage: parseFloat(mcr_coverage ?? '0'),
goodCoverage: parseFloat(good_coverage ?? '0'),
flagsIds: [...new Set(flags_list?.split(','))],
verifiedFlagsIds: [...new Set(verifiedd_flags_list?.split(','))],
verifiedFlagsIds: [...new Set(verified_flags_list?.split(','))],
undefinedQualityPeriodsCount: undefined_quality_periods_count,
},
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { SkimmingStage } from '../../../domain/enums/SkimmingStage.js';
import { NumericalComparisonFilterModel } from '../../../components/Filters/common/filters/NumericalComparisonFilterModel.js';
import { jsonFetch } from '../../../utilities/fetch/jsonFetch.js';
import { mergeRemoteData } from '../../../utilities/mergeRemoteData.js';
import { RemoteDataSource } from '../../../utilities/fetch/RemoteDataSource.js';
import { DetectorType } from '../../../domain/enums/DetectorTypes.js';

const ALL_CPASS_PRODUCTIONS_REGEX = /cpass\d+/;
Expand Down Expand Up @@ -58,6 +59,12 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo
this._markAsSkimmableRequestResult$ = new ObservableData(RemoteData.notAsked());
this._markAsSkimmableRequestResult$.bubbleTo(this);

this._gaqSummary$ = new ObservableData({});
this._gaqSummary$.bubbleTo(this);

this._gaqSummarySource = null;
this._gaqSequenceAbortController = null;

this._skimmableRuns$ = new ObservableData(RemoteData.notAsked());
this._skimmableRuns$.bubbleTo(this);

Expand All @@ -71,6 +78,8 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo

this._discardAllQcFlagsActionState$ = new ObservableData(RemoteData.notAsked());
this._discardAllQcFlagsActionState$.bubbleTo(this);

this._item$.observe(() => this._fetchGaqSummaryForCurrentRuns());
}

/**
Expand Down Expand Up @@ -321,6 +330,87 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo
}
}

/**
* Cancel all ongoing and future GAQ summary fetches
* @return {void} promise
*/
_abortGaqFetches() {
// Aborts the overall sequence fetch, i.e. stops further individual run fetches
this._gaqSequenceAbortController?.abort();

// Aborts individual run fetch in-flight
this._gaqSummarySource?._abortController?.abort();
}

/**
* Fetch GAQ summary for given data pass and run
* @param {number} [runNumber] run number to filter by
* @return {Promise<void>} resolves once data has been fetched
*/
async _fetchGaqSummary(runNumber) {
this._gaqSummarySource = new RemoteDataSource();
// Pipe the result into the correct slot in the gaqSummary$ observable
this._gaqSummarySource.pipe({
setCurrent: (remoteData) => {
const current = this._gaqSummary$.getCurrent();
this._gaqSummary$.setCurrent({
...current,
[runNumber]: remoteData.apply({ Success: (response) => response.data }),
});
},
});
const url = buildUrl('/api/qcFlags/summary/gaq', {
dataPassId: this._dataPassId,
mcReproducibleAsNotBad: this._mcReproducibleAsNotBad,
runNumber: runNumber,
});
await this._gaqSummarySource.fetch(url);
}

/**
* Fetch GAQ summary for currently displayed (paginated) runs
* @return {void}
*/
_fetchGaqSummaryForCurrentRuns() {
// Stop any previous fetch (quickly changing filters, pagination, etc)
this._abortGaqFetches();

// Reset abort controller
this._gaqSequenceAbortController = new AbortController();
const { signal } = this._gaqSequenceAbortController;

this._item$.getCurrent().match({
Success: async (runs) => {
const runNumbers = runs.map((run) => run.runNumber);

// Prepare GAQ summary object with NotAsked RemoteData state for all runs
let gaqSummary = {};
for (const runNumber of runNumbers) {
gaqSummary = { ...gaqSummary, [runNumber]: RemoteData.notAsked() };
}
this._gaqSummary$.setCurrent(gaqSummary);

// Trigger GAQ summary fetch for each run
for (const runNumber of runNumbers) {
if (signal.aborted) {
return;
}

try {
await this._fetchGaqSummary(runNumber);
} catch {
if (signal.aborted) {
return;
}
}
}
},
Other: () => {
// Don't fetch if runs haven't loaded successfully yet
},
});
}

/**
* Fetch skimmable runs for given data pass
* @return {Promise<void>} resolves once data are fetched
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumb
import { qcSummaryLegendTooltip } from '../../../components/qcFlags/qcSummaryLegendTooltip.js';
import { isRunNotSubjectToQc } from '../../../components/qcFlags/isRunNotSubjectToQc.js';
import { frontLink } from '../../../components/common/navigation/frontLink.js';
import { getQcSummaryDisplay } from '../ActiveColumns/getQcSummaryDisplay.js';
import errorAlert from '../../../components/common/errorAlert.js';
import { switchInput } from '../../../components/common/form/switchInput.js';
import { PdpBeamType } from '../../../domain/enums/PdpBeamType.js';
Expand Down Expand Up @@ -102,6 +103,7 @@ export const RunsPerDataPassOverviewPage = ({
detectors: remoteDetectors,
dataPass: remoteDataPass,
qcSummary: remoteQcSummary,
gaqSummary: remoteGaqSummary,
displayOptions,
dataPassId,
sortModel,
Expand All @@ -118,6 +120,9 @@ export const RunsPerDataPassOverviewPage = ({

return h(
'.intermediate-flex-column',
{ onremove: () => {
perDataPassOverviewModel._abortGaqFetches();
} },
mergeRemoteData([remoteDataPass, remoteRuns, remoteDetectors, remoteQcSummary]).match({
NotAsked: () => null,
Failure: (errors) => errorAlert(errors),
Expand Down Expand Up @@ -160,14 +165,21 @@ export const RunsPerDataPassOverviewPage = ({
),
visible: true,
format: (_, { runNumber }) => {
const gaqDisplay = h('button.btn.btn-primary.w-100', [
'GAQ',
h(
'.d-inline-block.va-t-bottom',
tooltip(h('.f7', iconWarning()), 'GAQ Summary is disabled, please click to view GAQ flags'),
),
]);
return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber });
const gaqLoadingSpinner = h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false }));
const runGaqSummary = remoteGaqSummary[runNumber];

return runGaqSummary.match({
Success: (gaqSummary) => {
const gaqDisplay = gaqSummary?.undefinedQualityPeriodsCount === 0
? getQcSummaryDisplay(gaqSummary)
: h('button.btn.btn-primary.w-100', 'GAQ');

return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber });
},
Loading: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'),
NotAsked: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'),
Failure: () => tooltip(iconWarning(), 'Failed to load GAQ summary'),
});
},
filter: ({ filteringModel }) => numericalComparisonFilter(
filteringModel.get('gaq[notBadFraction]'),
Expand Down
7 changes: 4 additions & 3 deletions lib/server/controllers/qcFlag.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,17 +382,18 @@ const getGaqQcFlagsHandler = async (request, response) => {
const getGaqSummaryHandler = async (request, response) => {
const validatedDTO = await dtoValidator(
DtoFactory.queryOnly(Joi.object({
dataPassId: Joi.number().required(),
dataPassId: Joi.number().positive().required(),
mcReproducibleAsNotBad: Joi.boolean().optional(),
runNumber: Joi.number().positive().required(),
})),
request,
response,
);
if (validatedDTO) {
try {
const { dataPassId, mcReproducibleAsNotBad = false } = validatedDTO.query;
const { dataPassId, mcReproducibleAsNotBad = false, runNumber } = validatedDTO.query;

const data = await gaqService.getSummary(dataPassId, { mcReproducibleAsNotBad });
const data = await gaqService.getSummary(dataPassId, { mcReproducibleAsNotBad, runNumber });
response.json({ data });
} catch (error) {
updateExpressResponseFromNativeError(response, error);
Expand Down
13 changes: 11 additions & 2 deletions lib/server/services/qualityControlFlag/GaqService.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ class GaqService {
* @param {object} [options] additional options
* @param {boolean} [options.mcReproducibleAsNotBad = false] if set to true,
* `Limited Acceptance MC Reproducible` flag type is treated as good one
* @param {number} [options.runNumber] Optional run number to filter by
* @return {Promise<GaqSummary>} Resolves with the GAQ Summary
*/
async getSummary(dataPassId, { mcReproducibleAsNotBad = false } = {}) {
async getSummary(dataPassId, { mcReproducibleAsNotBad = false, runNumber } = {}) {
await getOneDataPassOrFail({ id: dataPassId });
const gaqCoverages = await QcFlagRepository.getGaqCoverages(dataPassId);
const gaqCoverages = await QcFlagRepository.getGaqCoverages(dataPassId, runNumber);
const gaqSummary = Object.entries(gaqCoverages).map(([
runNumber,
{
Expand All @@ -71,6 +72,14 @@ class GaqService {
},
]);

/**
* If runNumber is specified, only one summary is returned but the getGaqCoverages
* returns still with runNumber as key, so we extract the single value from the array.
*/
if (runNumber && gaqSummary.length === 1) {
return Object.fromEntries(gaqSummary)[runNumber];
}

return Object.fromEntries(gaqSummary);
}

Expand Down
50 changes: 45 additions & 5 deletions test/api/qcFlags.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -556,22 +556,62 @@ module.exports = () => {
relations,
);

const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3');
const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3&runNumber=54');
expect(response.status).to.be.equal(200);
const { body: { data } } = response;
expect(data).to.be.eql({
54: {
missingVerificationsCount: 1,
mcReproducible: true,
badEffectiveRunCoverage: 1,
explicitlyNotBadEffectiveRunCoverage: 0,
undefinedQualityPeriodsCount: 0,
},
});
);
});

it('should return 400 when bad query parameter provided', async () => {
const response = await request(server).get('/api/qcFlags/summary/gaq');
it('should return empty GAQ summary if no data exists for given dataPassId & runNumber combination', async () => {
const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3&runNumber=999');
expect(response.status).to.equal(200);
const { body: { data } } = response;
expect(data).to.eql({});
});

it('should return 400 if dataPassId is not positive', async () => {
const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=-1&runNumber=54');
expect(response.status).to.equal(400);
expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a positive number');
});

it('should return 400 if runNumber is not positive', async () => {
const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3&runNumber=-10');
expect(response.status).to.equal(400);
expect(response.body.errors[0].detail).to.equal('"query.runNumber" must be a positive number');
});

it('should return 400 if dataPassId is not a number', async () => {
const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=abc&runNumber=54');
expect(response.status).to.equal(400);
const { errors } = response.body;
expect(errors[0].detail).to.equal('"query.dataPassId" must be a number');
});

it('should return 400 if runNumber is not a number', async () => {
const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3&runNumber=abc');
expect(response.status).to.equal(400);
const { errors } = response.body;
expect(errors[0].detail).to.equal('"query.runNumber" must be a number');
});

it('should return 400 when runNumber parameter is missing', async () => {
const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3');
expect(response.status).to.be.equal(400);
const { errors } = response.body;
const titleError = errors.find((err) => err.source.pointer === '/data/attributes/query/runNumber');
expect(titleError.detail).to.equal('"query.runNumber" is required');
});

it('should return 400 when dataPassId parameter is missing', async () => {
const response = await request(server).get('/api/qcFlags/summary/gaq?runNumber=54');
expect(response.status).to.be.equal(400);
const { errors } = response.body;
const titleError = errors.find((err) => err.source.pointer === '/data/attributes/query/dataPassId');
Expand Down
5 changes: 3 additions & 2 deletions test/public/runs/runsPerDataPass.overview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ module.exports = () => {

await expectInnerText(page, '#row106-globalAggregatedQuality', 'GAQ');

expect(await getPopoverInnerText(await page.waitForSelector('#row106-globalAggregatedQuality .popover-trigger')))
.to.be.equal('GAQ Summary is disabled, please click to view GAQ flags');
await expectInnerText(page, '#row107-globalAggregatedQuality', '76');
expect(await getPopoverInnerText(await page.waitForSelector('#row107-globalAggregatedQuality .popover-trigger')))
.to.be.equal('Missing 3 verifications');
});

it('should ignore QC flags created by services in QC summaries of AOT and MUON ', async () => {
Expand Down
Loading