Plate map for 24 well WW plate.

This commit is contained in:
lwark
2024-06-17 13:13:04 -05:00
parent 00f21abac7
commit 12e552800a
4 changed files with 127 additions and 48 deletions

View File

@@ -1,3 +1,8 @@
## 202406.04
- Adding in tips to Equipment usage.
- New WastewaterArticAssociation will track previously missed sample info.
## 202406.02 ## 202406.02
- Attached Contact to Submission. - Attached Contact to Submission.

View File

@@ -8,9 +8,9 @@ from getpass import getuser
import logging, uuid, tempfile, re, yaml, base64 import logging, uuid, tempfile, re, yaml, base64
from zipfile import ZipFile from zipfile import ZipFile
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from operator import attrgetter, itemgetter from operator import itemgetter
from pprint import pformat from pprint import pformat
from . import BaseClass, Reagent, SubmissionType, KitType, Organization, Contact, Tips, TipRole, SubmissionTipsAssociation from . import BaseClass, Reagent, SubmissionType, KitType, Organization, Contact, Tips
from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case
from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.orm import relationship, validates, Query
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@@ -19,14 +19,14 @@ from sqlalchemy.exc import OperationalError as AlcOperationalError, IntegrityErr
ArgumentError ArgumentError
from sqlite3 import OperationalError as SQLOperationalError, IntegrityError as SQLIntegrityError from sqlite3 import OperationalError as SQLOperationalError, IntegrityError as SQLIntegrityError
import pandas as pd import pandas as pd
from openpyxl import Workbook, load_workbook from openpyxl import Workbook
from openpyxl.worksheet.worksheet import Worksheet from openpyxl.worksheet.worksheet import Worksheet
from openpyxl.drawing.image import Image as OpenpyxlImage from openpyxl.drawing.image import Image as OpenpyxlImage
from tools import check_not_nan, row_map, setup_lookup, jinja_template_loading, rreplace from tools import row_map, setup_lookup, jinja_template_loading, rreplace, row_keys
from datetime import datetime, date from datetime import datetime, date
from typing import List, Any, Tuple, Literal from typing import List, Any, Tuple, Literal
from dateutil.parser import parse from dateutil.parser import parse
from dateutil.parser import ParserError # from dateutil.parser import ParserError
from pathlib import Path from pathlib import Path
from jinja2.exceptions import TemplateNotFound from jinja2.exceptions import TemplateNotFound
from jinja2 import Template from jinja2 import Template
@@ -67,7 +67,7 @@ class BasicSubmission(BaseClass):
String(64)) #: Permanent storage of used cost centre in case organization field changed in the future. String(64)) #: Permanent storage of used cost centre in case organization field changed in the future.
contact = relationship("Contact", back_populates="submissions") #: client org contact = relationship("Contact", back_populates="submissions") #: client org
contact_id = Column(INTEGER, ForeignKey("_contact.id", ondelete="SET NULL", contact_id = Column(INTEGER, ForeignKey("_contact.id", ondelete="SET NULL",
name="fk_BS_contact_id")) #: client lab id from _organizations name="fk_BS_contact_id")) #: client lab id from _organizations
submission_sample_associations = relationship( submission_sample_associations = relationship(
"SubmissionSampleAssociation", "SubmissionSampleAssociation",
@@ -126,7 +126,7 @@ class BasicSubmission(BaseClass):
Returns: Returns:
List[str]: List of column names List[str]: List of column names
""" """
output = [item.name for item in cls.__table__.columns if isinstance(item.type, JSON)] output = [item.name for item in cls.__table__.columns if isinstance(item.type, JSON)]
if issubclass(cls, BasicSubmission) and not cls.__name__ == "BasicSubmission": if issubclass(cls, BasicSubmission) and not cls.__name__ == "BasicSubmission":
output += BasicSubmission.jsons() output += BasicSubmission.jsons()
@@ -139,7 +139,7 @@ class BasicSubmission(BaseClass):
Returns: Returns:
List[str]: List of column names List[str]: List of column names
""" """
output = [item.name for item in cls.__table__.columns if isinstance(item.type, TIMESTAMP)] output = [item.name for item in cls.__table__.columns if isinstance(item.type, TIMESTAMP)]
if issubclass(cls, BasicSubmission) and not cls.__name__ == "BasicSubmission": if issubclass(cls, BasicSubmission) and not cls.__name__ == "BasicSubmission":
output += BasicSubmission.timestamps() output += BasicSubmission.timestamps()
@@ -198,12 +198,12 @@ class BasicSubmission(BaseClass):
Returns: Returns:
SubmissionType: SubmissionType with name equal to this polymorphic identity SubmissionType: SubmissionType with name equal to this polymorphic identity
""" """
name = cls.__mapper_args__['polymorphic_identity'] name = cls.__mapper_args__['polymorphic_identity']
return SubmissionType.query(name=name) return SubmissionType.query(name=name)
@classmethod @classmethod
def construct_info_map(cls, mode:Literal["read", "write"]) -> dict: def construct_info_map(cls, mode: Literal["read", "write"]) -> dict:
""" """
Method to call submission type's construct info map. Method to call submission type's construct info map.
@@ -212,7 +212,7 @@ class BasicSubmission(BaseClass):
Returns: Returns:
dict: Map of info locations. dict: Map of info locations.
""" """
return cls.get_submission_type().construct_info_map(mode=mode) return cls.get_submission_type().construct_info_map(mode=mode)
@classmethod @classmethod
@@ -222,9 +222,14 @@ class BasicSubmission(BaseClass):
Returns: Returns:
dict: sample location map dict: sample location map
""" """
return cls.get_submission_type().construct_sample_map() return cls.get_submission_type().construct_sample_map()
@classmethod
def finalize_details(cls, input_dict: dict) -> dict:
del input_dict['id']
return input_dict
def to_dict(self, full_data: bool = False, backup: bool = False, report: bool = False) -> dict: def to_dict(self, full_data: bool = False, backup: bool = False, report: bool = False) -> dict:
""" """
Constructs dictionary used in submissions summary Constructs dictionary used in submissions summary
@@ -317,7 +322,7 @@ class BasicSubmission(BaseClass):
try: try:
contact = self.contact.name contact = self.contact.name
except AttributeError as e: except AttributeError as e:
logger.error(f"Problem setting contact: {e}") # logger.error(f"Problem setting contact: {e}")
contact = "NA" contact = "NA"
try: try:
contact_phone = self.contact.phone contact_phone = self.contact.phone
@@ -372,7 +377,7 @@ class BasicSubmission(BaseClass):
else: else:
try: try:
self.run_cost = assoc.constant_cost + (assoc.mutable_cost_column * cols_count_96) + ( self.run_cost = assoc.constant_cost + (assoc.mutable_cost_column * cols_count_96) + (
assoc.mutable_cost_sample * int(self.sample_count)) assoc.mutable_cost_sample * int(self.sample_count))
except Exception as e: except Exception as e:
logger.error(f"Calculation error: {e}") logger.error(f"Calculation error: {e}")
self.run_cost = round(self.run_cost, 2) self.run_cost = round(self.run_cost, 2)
@@ -387,7 +392,8 @@ class BasicSubmission(BaseClass):
output_list = [assoc.to_hitpick() for assoc in self.submission_sample_associations] output_list = [assoc.to_hitpick() for assoc in self.submission_sample_associations]
return output_list return output_list
def make_plate_map(self, plate_rows: int = 8, plate_columns=12) -> str: @classmethod
def make_plate_map(cls, sample_list: list, plate_rows: int = 8, plate_columns=12) -> str:
""" """
Constructs an html based plate map for submission details. Constructs an html based plate map for submission details.
@@ -399,17 +405,6 @@ class BasicSubmission(BaseClass):
Returns: Returns:
str: html output string. str: html output string.
""" """
# logger.debug("Creating basic hitpick")
sample_list = self.hitpick_plate()
# logger.debug("Setting background colours")
for sample in sample_list:
if sample['positive']:
sample['background_color'] = "#f10f07"
else:
if "colour" in sample.keys():
sample['background_color'] = "#69d84f"
else:
sample['background_color'] = "#80cbc4"
output_samples = [] output_samples = []
# logger.debug("Setting locations.") # logger.debug("Setting locations.")
for column in range(1, plate_columns + 1): for column in range(1, plate_columns + 1):
@@ -434,7 +429,8 @@ class BasicSubmission(BaseClass):
return [item.role for item in self.submission_equipment_associations] return [item.role for item in self.submission_equipment_associations]
@classmethod @classmethod
def submissions_to_df(cls, submission_type: str | None = None, limit: int = 0, chronologic:bool=True) -> pd.DataFrame: def submissions_to_df(cls, submission_type: str | None = None, limit: int = 0,
chronologic: bool = True) -> pd.DataFrame:
""" """
Convert all submissions to dataframe Convert all submissions to dataframe
@@ -448,7 +444,8 @@ class BasicSubmission(BaseClass):
# logger.debug(f"Querying Type: {submission_type}") # logger.debug(f"Querying Type: {submission_type}")
# logger.debug(f"Using limit: {limit}") # logger.debug(f"Using limit: {limit}")
# use lookup function to create list of dicts # use lookup function to create list of dicts
subs = [item.to_dict() for item in cls.query(submission_type=submission_type, limit=limit, chronologic=chronologic)] subs = [item.to_dict() for item in
cls.query(submission_type=submission_type, limit=limit, chronologic=chronologic)]
# logger.debug(f"Got {len(subs)} submissions.") # logger.debug(f"Got {len(subs)} submissions.")
df = pd.DataFrame.from_records(subs) df = pd.DataFrame.from_records(subs)
# logger.debug(f"Column names: {df.columns}") # logger.debug(f"Column names: {df.columns}")
@@ -456,7 +453,8 @@ class BasicSubmission(BaseClass):
excluded = ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents', excluded = ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents',
'equipment', 'gel_info', 'gel_image', 'dna_core_submission_number', 'gel_controls', 'equipment', 'gel_info', 'gel_image', 'dna_core_submission_number', 'gel_controls',
'source_plates', 'pcr_technician', 'ext_technician', 'artic_technician', 'cost_centre', 'source_plates', 'pcr_technician', 'ext_technician', 'artic_technician', 'cost_centre',
'signed_by', 'artic_date', 'gel_barcode', 'gel_date', 'ngs_date', 'contact_phone', 'contact', 'tips'] 'signed_by', 'artic_date', 'gel_barcode', 'gel_date', 'ngs_date', 'contact_phone', 'contact',
'tips']
for item in excluded: for item in excluded:
try: try:
df = df.drop(item, axis=1) df = df.drop(item, axis=1)
@@ -1163,6 +1161,7 @@ class BasicSubmission(BaseClass):
writer = pyd.to_writer() writer = pyd.to_writer()
writer.xl.save(filename=fname.with_suffix(".xlsx")) writer.xl.save(filename=fname.with_suffix(".xlsx"))
# Below are the custom submission types # Below are the custom submission types
class BacterialCulture(BasicSubmission): class BacterialCulture(BasicSubmission):
@@ -1235,7 +1234,7 @@ class BacterialCulture(BasicSubmission):
new_lot = matched.group() new_lot = matched.group()
try: try:
pos_control_reg = \ pos_control_reg = \
[reg for reg in input_dict['reagents'] if reg['role'] == "Bacterial-Positive Control"][0] [reg for reg in input_dict['reagents'] if reg['role'] == "Bacterial-Positive Control"][0]
except IndexError: except IndexError:
logger.error(f"No positive control reagent listed") logger.error(f"No positive control reagent listed")
return input_dict return input_dict
@@ -1397,6 +1396,39 @@ class Wastewater(BasicSubmission):
row = idx.index.to_list()[0] row = idx.index.to_list()[0]
return row + 1 return row + 1
@classmethod
def get_details_template(cls, base_dict: dict) -> Tuple[dict, Template]:
"""
Get the details jinja template for the correct class. Extends parent
Args:
base_dict (dict): incoming dictionary of Submission fields
Returns:
Tuple[dict, Template]: (Updated dictionary, Template to be rendered)
"""
base_dict, template = super().get_details_template(base_dict=base_dict)
base_dict['excluded'] += ['origin_plate']
return base_dict, template
@classmethod
def finalize_details(cls, input_dict: dict) -> dict:
input_dict = super().finalize_details(input_dict)
dummy_samples = []
for item in input_dict['samples']:
# logger.debug(f"Sample dict: {item}")
thing = item
try:
thing['row'] = thing['source_row']
thing['column'] = thing['source_column']
except KeyError:
logger.error(f"No row or column for sample: {item['submitter_id']}")
continue
thing['tooltip'] = f"Sample Name: {thing['name']}\nWell: {thing['sample_location']}"
dummy_samples.append(thing)
input_dict['origin_plate'] = cls.make_plate_map(sample_list=dummy_samples, plate_rows=4, plate_columns=6)
return input_dict
def custom_context_events(self) -> dict: def custom_context_events(self) -> dict:
events = super().custom_context_events() events = super().custom_context_events()
events['Link PCR'] = self.link_pcr events['Link PCR'] = self.link_pcr
@@ -1479,7 +1511,6 @@ class WastewaterArtic(BasicSubmission):
dict: Updated sample dictionary dict: Updated sample dictionary
""" """
input_dict = super().custom_info_parser(input_dict) input_dict = super().custom_info_parser(input_dict)
# workbook = load_workbook(xl.io, data_only=True)
ws = xl['Egel results'] ws = xl['Egel results']
data = [ws.cell(row=ii, column=jj) for jj in range(15, 27) for ii in range(10, 18)] data = [ws.cell(row=ii, column=jj) for jj in range(15, 27) for ii in range(10, 18)]
data = [cell for cell in data if cell.value is not None and "NTC" in cell.value] data = [cell for cell in data if cell.value is not None and "NTC" in cell.value]
@@ -1994,7 +2025,7 @@ class BasicSample(BaseClass):
return model return model
@classmethod @classmethod
def sql_enforcer(cls, pyd_sample:"PydSample"): def sql_enforcer(cls, pyd_sample: "PydSample"):
return pyd_sample return pyd_sample
@classmethod @classmethod
@@ -2088,7 +2119,7 @@ class BasicSample(BaseClass):
disallowed = ["id"] disallowed = ["id"]
if kwargs == {}: if kwargs == {}:
raise ValueError("Need to narrow down query or the first available instance will be returned.") raise ValueError("Need to narrow down query or the first available instance will be returned.")
sanitized_kwargs = {k:v for k,v in kwargs.items() if k not in disallowed} sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed}
instance = cls.query(sample_type=sample_type, limit=1, **kwargs) instance = cls.query(sample_type=sample_type, limit=1, **kwargs)
# logger.debug(f"Retrieved instance: {instance}") # logger.debug(f"Retrieved instance: {instance}")
if instance is None: if instance is None:
@@ -2100,11 +2131,11 @@ class BasicSample(BaseClass):
@classmethod @classmethod
def fuzzy_search(cls, def fuzzy_search(cls,
# submitter_id: str | None = None, # submitter_id: str | None = None,
sample_type: str | BasicSample | None = None, sample_type: str | BasicSample | None = None,
# limit: int = 0, # limit: int = 0,
**kwargs **kwargs
) -> List[BasicSample]: ) -> List[BasicSample]:
match sample_type: match sample_type:
case str(): case str():
model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type) model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type)
@@ -2130,10 +2161,9 @@ class BasicSample(BaseClass):
def get_searchables(cls): def get_searchables(cls):
return [dict(label="Submitter ID", field="submitter_id")] return [dict(label="Submitter ID", field="submitter_id")]
@classmethod @classmethod
def samples_to_df(cls, sample_type: str | None | BasicSample = None, **kwargs): def samples_to_df(cls, sample_type: str | None | BasicSample = None, **kwargs):
# def samples_to_df(cls, sample_type:str|None|BasicSample=None, searchables:dict={}): # def samples_to_df(cls, sample_type:str|None|BasicSample=None, searchables:dict={}):
logger.debug(f"Checking {sample_type} with type {type(sample_type)}") logger.debug(f"Checking {sample_type} with type {type(sample_type)}")
match sample_type: match sample_type:
case str(): case str():
@@ -2178,6 +2208,7 @@ class BasicSample(BaseClass):
if dlg.exec(): if dlg.exec():
pass pass
# Below are the custom sample types # Below are the custom sample types
class WastewaterSample(BasicSample): class WastewaterSample(BasicSample):
@@ -2309,6 +2340,7 @@ class BacterialCultureSample(BasicSample):
# logger.debug(f"Done converting to {self} to dict after {time()-start}") # logger.debug(f"Done converting to {self} to dict after {time()-start}")
return sample return sample
# Submission to Sample Associations # Submission to Sample Associations
class SubmissionSampleAssociation(BaseClass): class SubmissionSampleAssociation(BaseClass):
@@ -2322,7 +2354,7 @@ class SubmissionSampleAssociation(BaseClass):
submission_id = Column(INTEGER, ForeignKey("_basicsubmission.id"), primary_key=True) #: id of associated submission submission_id = Column(INTEGER, ForeignKey("_basicsubmission.id"), primary_key=True) #: id of associated submission
row = Column(INTEGER, primary_key=True) #: row on the 96 well plate row = Column(INTEGER, primary_key=True) #: row on the 96 well plate
column = Column(INTEGER, primary_key=True) #: column on the 96 well plate column = Column(INTEGER, primary_key=True) #: column on the 96 well plate
submission_rank = Column(INTEGER, nullable=False, default=0) #: Location in sample list submission_rank = Column(INTEGER, nullable=False, default=0) #: Location in sample list
# reference to the Submission object # reference to the Submission object
submission = relationship(BasicSubmission, submission = relationship(BasicSubmission,
@@ -2353,7 +2385,7 @@ class SubmissionSampleAssociation(BaseClass):
else: else:
self.id = self.__class__.autoincrement_id() self.id = self.__class__.autoincrement_id()
# logger.debug(f"Looking at kwargs: {pformat(kwargs)}") # logger.debug(f"Looking at kwargs: {pformat(kwargs)}")
for k,v in kwargs.items(): for k, v in kwargs.items():
try: try:
self.__setattr__(k, v) self.__setattr__(k, v)
except AttributeError: except AttributeError:
@@ -2405,11 +2437,19 @@ class SubmissionSampleAssociation(BaseClass):
env = jinja_template_loading() env = jinja_template_loading()
template = env.get_template("tooltip.html") template = env.get_template("tooltip.html")
tooltip_text = template.render(fields=sample) tooltip_text = template.render(fields=sample)
try:
control = self.sample.control
except AttributeError:
control = None
if control is not None:
background = "rgb(128, 203, 196)"
else:
background = "rgb(105, 216, 79)"
try: try:
tooltip_text += sample['tooltip'] tooltip_text += sample['tooltip']
except KeyError: except KeyError:
pass pass
sample.update(dict(Name=self.sample.submitter_id[:10], tooltip=tooltip_text)) sample.update(dict(Name=self.sample.submitter_id[:10], tooltip=tooltip_text, background_color=background))
return sample return sample
@classmethod @classmethod
@@ -2524,7 +2564,7 @@ class SubmissionSampleAssociation(BaseClass):
association_type: str = "Basic Association", association_type: str = "Basic Association",
submission: BasicSubmission | str | None = None, submission: BasicSubmission | str | None = None,
sample: BasicSample | str | None = None, sample: BasicSample | str | None = None,
id:int|None=None, id: int | None = None,
**kwargs) -> SubmissionSampleAssociation: **kwargs) -> SubmissionSampleAssociation:
""" """
Queries for an association, if none exists creates a new one. Queries for an association, if none exists creates a new one.
@@ -2601,6 +2641,11 @@ class WastewaterAssociation(SubmissionSampleAssociation):
sample = super().to_sub_dict() sample = super().to_sub_dict()
sample['ct'] = f"({self.ct_n1}, {self.ct_n2})" sample['ct'] = f"({self.ct_n1}, {self.ct_n2})"
try:
sample['source_row'] = row_keys[self.sample.sample_location[0]]
sample['source_column'] = int(self.sample.sample_location[1:])
except (TypeError, AttributeError) as e:
logger.error(f"Couldn't set sources for {self.sample.rsl_number}. Looks like there isn't data.")
try: try:
sample['positive'] = any(["positive" in item for item in [self.n1_status, self.n2_status]]) sample['positive'] = any(["positive" in item for item in [self.n1_status, self.n2_status]])
except (TypeError, AttributeError) as e: except (TypeError, AttributeError) as e:
@@ -2615,6 +2660,18 @@ class WastewaterAssociation(SubmissionSampleAssociation):
dict: dictionary of sample id, row and column in elution plate dict: dictionary of sample id, row and column in elution plate
""" """
sample = super().to_hitpick() sample = super().to_hitpick()
try:
scaler = max([self.ct_n1, self.ct_n2])
except TypeError:
scaler = 0.0
if scaler == 0.0:
scaler = 45
bg = (45 - scaler) * 17
red = min([128 + bg, 255])
grn = max([255 - bg, 0])
blu = 128
rgb = f"rgb({red}, {grn}, {blu})"
sample['background_color'] = rgb
try: try:
sample[ sample[
'tooltip'] += f"<br>- ct N1: {'{:.2f}'.format(self.ct_n1)} ({self.n1_status})<br>- ct N2: {'{:.2f}'.format(self.ct_n2)} ({self.n2_status})" 'tooltip'] += f"<br>- ct N1: {'{:.2f}'.format(self.ct_n1)} ({self.n1_status})<br>- ct N2: {'{:.2f}'.format(self.ct_n2)} ({self.n2_status})"
@@ -2682,5 +2739,3 @@ class WastewaterArticAssociation(SubmissionSampleAssociation):
except ValueError as e: except ValueError as e:
logger.error(f"Problem incrementing id: {e}") logger.error(f"Problem incrementing id: {e}")
return 1 return 1

View File

@@ -86,10 +86,11 @@ class SubmissionDetails(QDialog):
self.base_dict = submission.to_dict(full_data=True) self.base_dict = submission.to_dict(full_data=True)
# logger.debug(f"Submission details data:\n{pformat({k:v for k,v in self.base_dict.items() if k != 'samples'})}") # logger.debug(f"Submission details data:\n{pformat({k:v for k,v in self.base_dict.items() if k != 'samples'})}")
# NOTE: don't want id # NOTE: don't want id
del self.base_dict['id'] self.base_dict = submission.finalize_details(self.base_dict)
# del self.base_dict['id']
# logger.debug(f"Creating barcode.") # logger.debug(f"Creating barcode.")
# logger.debug(f"Making platemap...") # logger.debug(f"Making platemap...")
self.base_dict['platemap'] = submission.make_plate_map() self.base_dict['platemap'] = BasicSubmission.make_plate_map(sample_list=submission.hitpick_plate())
self.base_dict, self.template = submission.get_details_template(base_dict=self.base_dict) self.base_dict, self.template = submission.get_details_template(base_dict=self.base_dict)
self.html = self.template.render(sub=self.base_dict, signing_permission=is_power_user()) self.html = self.template.render(sub=self.base_dict, signing_permission=is_power_user())
self.webview.setHtml(self.html) self.webview.setHtml(self.html)

View File

@@ -0,0 +1,18 @@
{% extends "basicsubmission_details.html" %}
<head>
{% block head %}
{{ super() }}
{% endblock %}
</head>
<body>
{% block body %}
{{ super() }}
{% if sub['origin_plate'] %}
<br/>
<h3><u>24 Well Plate:</u></h3>
{{ sub['origin_plate'] }}
{% endif %}
{% endblock %}
</body>