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

Actually render product metrics #232

Merged
merged 8 commits into from
Jul 11, 2024
2 changes: 1 addition & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup>
import { RouterView, useRoute } from 'vue-router';
import { onMounted, provide, ref } from "vue";
import { ElMessage } from "element-plus";
import { ElMessage, ElTooltip } from "element-plus";
import { Search } from '@element-plus/icons-vue';

import router from "@/router/index.js";
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/components/PoolStats.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup>
import { ref } from "vue";
import { ElTooltip, ElCollapse, ElCollapseItem } from "element-plus";

const props = defineProps({
pool: Object,
})

const active = ref([])
</script>

<template>
<el-collapse v-model="active" accordion>
<el-collapse-item name="1">
<template #title><slot>Pool composition</slot></template>
<el-tooltip content="Caution, this value is of low signficance when the pool size is small">
<p>Coefficient of Variance: {{ props.pool.pool_coeff_of_variance }}</p>
</el-tooltip>
<table>
<tr>
<th>Tag 1</th>
<th>Tag 2</th>
<th>Deplexing barcode ID</th>
<th>HiFi bases (Gb)</th>
<th>HiFi reads</th>
<th>HiFi mean read length</th>
<th>Percentage of HiFi bases</th>
<th>Percentage of total reads</th>
</tr>
<tr :key="library.id_product" v-for="library in props.pool.products">
<td>{{ library.tag1_name }}</td>
<td>{{ library.tag2_name }}</td>
<td>{{ library.deplexing_barcode }}</td>
<td>{{ library.hifi_read_bases }}</td>
<td>{{ library.hifi_num_reads }}</td>
<td>{{ library.hifi_read_length_mean }}</td>
<td>{{ library.hifi_bases_percent }}</td>
<td>{{ library.percentage_total_reads }}</td>
</tr>
</table>
</el-collapse-item>
</el-collapse>
</template>
33 changes: 30 additions & 3 deletions frontend/src/components/QcView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,36 @@
* An information view containing run data and metrics useful for QC assessment
*/

import { computed } from "vue";
import { computed, ref, watch } from "vue";
import groupMetrics from "../utils/metrics.js";
import { combineLabelWithPlate } from "../utils/text.js"
import PoolStats from "./PoolStats.vue";
import LangQc from "../utils/langqc";


const dataClient = new LangQc()

const props = defineProps({
// Well object representing one prepared input for the instrument
// Expects content in the form of lang_qc/models/pacbio/well.py:PacBioWellFull
well: Object,
});
})

const poolStats = ref(null)
watch(() => props.well, () => {
poolStats.value = null // empty in case next well doesn't have a pool
dataClient.getPoolMetrics(props.well.id_product).then(
(response) => { poolStats.value = response }
).catch((error) => {
if (error.message.match("Conflict")) {
// Nothing to do
} else {
console.log(error)
// make a banner show this error?
}
})
}, { immediate: true}
)

const slURL = computed(() => {
let hostname = props.well.metrics.smrt_link.hostname
Expand Down Expand Up @@ -97,7 +118,6 @@
}
return ''
})

</script>

<template>
Expand Down Expand Up @@ -165,6 +185,8 @@
</table>
</div>

<PoolStats v-if="poolStats" :pool="poolStats">Pool composition</PoolStats>

<div id="Metrics">
<table>
<tr>
Expand Down Expand Up @@ -197,6 +219,11 @@
td {
padding-left: 5px;
padding-right: 5px;
align-self: flex-start;
}
tr>td:first-child {
vertical-align: top;
/* Pin first text element to the top of the td */
}
table.summary {
border: 0px;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/WellTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ defineEmits(['wellSelected'])
<th>Well time complete</th>
</tr>
<tr :key="wellObj.id_product" v-for="wellObj in wellCollection">
<td>{{ wellObj.run_name }}</td>
<td :id="wellObj.run_name">{{ wellObj.run_name }}</td>
<td class="well_selector">
<el-tooltip placement="top" effect="light" :show-after="tooltipDelay"
:content="'<span>'.concat(listStudiesForTooltip(wellObj.study_names)).concat('</span>')"
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/components/__tests__/PoolStats.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest'
import { mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'

import PoolStats from '../PoolStats.vue'

const wrapper = mount(PoolStats, {
global: {
plugins: [ElementPlus],
},
props: {
pool: {
pool_coeff_of_variance: 47.2,
products: [{
id_product: 'A'.repeat(64),
tag1_name: 'TTTTTTTT',
tag2_name: null,
deplexing_barcode: 'bc10--bc10',
hifi_read_bases: 900,
hifi_num_reads: 20,
hifi_read_length_mean: 45,
hifi_bases_percent: 90.001,
percentage_total_reads: 66.6
},{
id_product: 'B'.repeat(64),
tag1_name: 'GGGGGGGG',
tag2_name: null,
deplexing_barcode: 'bc11--bc11',
hifi_read_bases: 100,
hifi_num_reads: 10,
hifi_read_length_mean: 10,
hifi_bases_percent: 100,
percentage_total_reads: 33.3
}]
}
}
})

describe('Create poolstats table with good data', () => {
test('Component is "folded" by default', () => {
expect(wrapper.getComponent('transition-stub').attributes()['appear']).toEqual('false')
})

test('Coefficient of variance showing', async () => {
let topStat = wrapper.find('p')
await topStat.trigger('focus')
expect(topStat.classes('el-tooltip__trigger')).toBeTruthy()

expect(topStat.text()).toEqual('Coefficient of Variance: 47.2')
})

test('Table looks about right', () => {
let rows = wrapper.findAll('tr')
expect(rows.length).toEqual(3)

// Check tag 1 has been set
expect(rows[1].find('td').text()).toEqual('TTTTTTTT')
expect(rows[2].find('td').text()).toEqual('GGGGGGGG')
})
})
5 changes: 5 additions & 0 deletions frontend/src/components/__tests__/QcView.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import QcView from '../QcView.vue';

describe('Component renders', () => {

// Supply PoolStats element with some data
fetch.mockResponse(
JSON.stringify({})
)

afterEach(async () => {
cleanup()
});
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/utils/__tests__/langqc.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ describe('Example fake remote api call', () => {

client.getWellsForRunPromise('blah')
expect(fetch.mock.calls[6][0]).toEqual('/api/pacbio/run/blah?page_size=100&page=1')

client.getPoolMetrics('A12345');
expect(fetch.mock.calls[7][0]).toEqual('/api/pacbio/products/A12345/seq_level/pool')
});
});

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/utils/langqc.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,10 @@ export default class LangQc {
}
)
}

getPoolMetrics(id_product) {
// Use the product metrics endpoint to get additional metrics
// for a well.
return this.fetchWrapper(this.buildUrl(['products', id_product, 'seq_level', 'pool']));
}
}
20 changes: 10 additions & 10 deletions frontend/src/views/__tests__/WellsByRun.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ describe('Does it work?', async () => {

test('Click a well selection, QC View appears (because URL alters)', async () => {
// Not providing precisely the right data, but serves for the component
fetch.mockResponseOnce(
JSON.stringify(secondaryRun)
fetch.mockResponses(
[JSON.stringify(secondaryRun)], // well QC data loading
[JSON.stringify({})] // Pool stats loading
)

let buttons = wrapper.findAll('button')
Expand Down Expand Up @@ -168,16 +169,15 @@ describe('Does it work?', async () => {
]
)
await wrapper.setProps({runName: ['TRACTION-RUN-211', 'TRACTION-RUN-210']})
await flushPromises()

test('Table now contains wells from both runs', () => {
const table = wrapper.get('table')
expect(table.exists()).toBe(true)
const table = wrapper.get('table')
expect(table.exists()).toBe(true)

expect(table.find('TRACTION-RUN-211').exists()).toBe(true)
expect(table.find('TRACTION-RUN-210').exists()).toBe(true)
expect(table.find("td#TRACTION-RUN-211").exists()).toBe(true)
expect(table.find("td#TRACTION-RUN-210").exists()).toBe(true)

const rows = table.findAll('tr')
expect(rows.length).toEqual(4)
})
const rows = table.findAll('tr')
expect(rows.length).toEqual(4)
})
})
8 changes: 4 additions & 4 deletions lang_qc/endpoints/pacbio_well.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,17 +225,17 @@ def get_seq_metrics(
status.HTTP_409_CONFLICT: {"description": "Missing or incomplete LIMS data"},
status.HTTP_422_UNPROCESSABLE_ENTITY: {"description": "Invalid product ID"},
},
response_model=QCPoolMetrics,
response_model=QCPoolMetrics | None,
)
def get_product_metrics(
id_product: PacBioWellSHA256, mlwhdb_session: Session = Depends(get_mlwh_db)
) -> QCPoolMetrics:
) -> QCPoolMetrics | None:

mlwh_well = _find_well_product_or_error(id_product, mlwhdb_session)
try:
metrics = QCPoolMetrics(db_well=mlwh_well)
except MissingLimsDataError as err:
raise HTTPException(409, detail=str(err))
except MissingLimsDataError:
return

return metrics

Expand Down
12 changes: 6 additions & 6 deletions lang_qc/models/pacbio/qc_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ class SampleDeplexingStats(BaseModel):
tag1_name: str | None
tag2_name: str | None
deplexing_barcode: str | None
hifi_read_bases: int | None
hifi_read_bases: float | None = Field(title="HiFi read bases (Gb)")
hifi_num_reads: int | None
hifi_read_length_mean: float | None
hifi_bases_percent: float | None
Expand Down Expand Up @@ -208,7 +208,7 @@ def pre_root(cls, values: dict[str, Any]) -> dict[str, Any]:
if well is None:
raise ValueError(f"None {db_well_key_name} value is not allowed.")

cov: float = None
cov: float | None = None
sample_stats = []

if well.demultiplex_mode and "Instrument" in well.demultiplex_mode:
Expand All @@ -227,21 +227,21 @@ def pre_root(cls, values: dict[str, Any]) -> dict[str, Any]:
cov = None
else:
hifi_reads = [prod.hifi_num_reads for prod in product_metrics]
cov = stdev(hifi_reads) / mean(hifi_reads) * 100
cov = round(stdev(hifi_reads) / mean(hifi_reads) * 100, 2)

for (i, prod) in enumerate(product_metrics):
for i, prod in enumerate(product_metrics):
sample_stats.append(
SampleDeplexingStats(
id_product=prod.id_pac_bio_product,
tag1_name=lib_lims_data[i].tag_identifier,
tag2_name=lib_lims_data[i].tag2_identifier,
deplexing_barcode=prod.barcode4deplexing,
hifi_read_bases=prod.hifi_read_bases,
hifi_read_bases=convert_to_gigabase(prod, "hifi_read_bases"),
hifi_num_reads=prod.hifi_num_reads,
hifi_read_length_mean=prod.hifi_read_length_mean,
hifi_bases_percent=prod.hifi_bases_percent,
percentage_total_reads=(
prod.hifi_num_reads / well.hifi_num_reads * 100
round(prod.hifi_num_reads / well.hifi_num_reads * 100, 2)
if (well.hifi_num_reads and prod.hifi_num_reads)
else None
),
Expand Down
12 changes: 6 additions & 6 deletions tests/fixtures/sample_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ def simplex_run(request, mlwhdb_test_session):
tags=tag1,
).hash_product_id(),
qc=1,
hifi_read_bases=900,
hifi_read_bases=90000000,
hifi_num_reads=10,
hifi_read_length_mean=90,
hifi_read_length_mean=9000000,
barcode_quality_score_mean=34,
hifi_bases_percent=90.001,
pac_bio_run_well_metrics=well_metrics_a1,
Expand Down Expand Up @@ -148,9 +148,9 @@ def multiplexed_run(mlwhdb_test_session):
tags=tag1,
).hash_product_id(),
qc=1,
hifi_read_bases=900,
hifi_read_bases=90000000,
hifi_num_reads=20,
hifi_read_length_mean=45,
hifi_read_length_mean=4500000,
barcode_quality_score_mean=34,
hifi_bases_percent=90.001,
pac_bio_run_well_metrics=well_metrics_b1,
Expand Down Expand Up @@ -180,9 +180,9 @@ def multiplexed_run(mlwhdb_test_session):
tags=tag1_2,
).hash_product_id(),
qc=1,
hifi_read_bases=100,
hifi_read_bases=10000000,
hifi_num_reads=10,
hifi_read_length_mean=10,
hifi_read_length_mean=1000000,
barcode_quality_score_mean=34,
hifi_bases_percent=100.00,
pac_bio_run_well_metrics=well_metrics_b1,
Expand Down
16 changes: 8 additions & 8 deletions tests/test_pac_bio_qc_data_well.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,19 @@ def test_pool_metrics_from_well(mlwhdb_test_session, multiplexed_run):
for metrics in [metrics_via_db, metrics_direct]:
assert (
int(metrics.pool_coeff_of_variance) == 47
), "Variance between 20 and 10 is ~47%"
), "Variance between 20 reads and 10 reads is ~47%"

assert metrics.products[0].hifi_read_bases == 100
assert metrics.products[0].hifi_read_bases == 0.01
assert (
metrics.products[1].hifi_read_bases == 900
), "hifi read base counts are faithfully copied"
metrics.products[1].hifi_read_bases == 0.09
), "hifi read base counts are scaled to Gigabases"

assert (
int(metrics.products[0].percentage_total_reads) == 33
), "10 of 30 reads is 33.3%"
metrics.products[0].percentage_total_reads == 33.33
), "10Mb of 30Mb reads is 33.33% (2 d.p.)"
assert (
int(metrics.products[1].percentage_total_reads) == 66
), "20 of 30 reads is 66.6%"
metrics.products[1].percentage_total_reads == 66.67
), "20Mb of 30Mb reads is 66.67% (2 d.p.)"


def test_errors_instantiating_pool_metrics(mlwhdb_test_session):
Expand Down