Source code for dachs.structure

import logging
import os
import sys
from pathlib import Path
from typing import List

import chempy  # we only need a tiny bit, but it does offer options...
import pandas as pd

from dachs import ureg
from dachs.metaclasses import ChemicalsClass, Experiment
from dachs.readers import ReadStartingCompounds, find_in_log, readExperimentalSetup, readRawMessageLog
from dachs.reagent import Chemical, Mixture, Product
from dachs.synthesis import DerivedParameter, RawLogMessage, SynthesisClass


[docs] def DParFromLogEntry(ID: str, ParameterName: str, Description: str, LogEntry: RawLogMessage): return DerivedParameter( ID=ID, ParameterName=ParameterName, Description=Description, RawMessages=[LogEntry.Index], Quantity=LogEntry.Quantity, Value=LogEntry.Value, Unit=LogEntry.Unit, )
[docs] def setupLogging(outLogFilePathBase: Path): # accept all log levels, this 'overrides' any handler settings, # handlers will not see anything lower than that # see https://docs.python.org/3/library/logging.html#logging-levels logging.basicConfig(level=logging.NOTSET, stream=sys.stdout) logger = logging.getLogger() warnfn = outLogFilePathBase.with_name(outLogFilePathBase.name + "_DachsifyWarnings.log") infofn = outLogFilePathBase.with_name(outLogFilePathBase.name + "_DachsifyInfo.log") fh = logging.FileHandler(infofn, mode="w") # fh.setLevel(logging.INFO) # not defining = NOTSET, catch all logger.addHandler(fh) fh2 = logging.FileHandler(warnfn, mode="w") fh2.setLevel(logging.WARNING) logger.addHandler(fh2) print("Installed log handlers:") for lh in logger.handlers: print(" ", lh)
[docs] def create(logFile: Path, solFiles: List[Path], synFile: Path, amset: str = None) -> Experiment: """ Construction of a test structure from Glen's excel files using the available dataclasses, the hope is to use this as a template to construct the ontology, then write the structure to HDF5 files. It now defines: - base Chemicals and - Mixtures **TODO**: - write synthesis log - write or (perhaps better) link to SAS data structure. - write or (perhaps better) link to analysis results? :param logFile: Path to the robot log book Excel file :param solFiles: One or more Excel files describing the base solutions which were mixed by the robot :param synFile: The synthesis robot log file. """ setupLogging(Path(synFile.parent, synFile.stem)) logging.info(f"Working in '{os.getcwd()}'.") # define a ZIF 8 Chemical, we'll need this later: z8 = chempy.Substance.from_formula("C8H10N4Zn") zifChemical = Chemical( ChemicalID="ZIF-8", ChemicalName="Zeolitic Imidazolate Framework 8", ChemicalFormula="C8H10N4Zn", Substance=z8, MolarMass=ureg.Quantity(str(z8.molar_mass())), Density=ureg.Quantity("0.9426 g/cc"), SourceDOI="10.1038/s42004-021-00613-z", SpaceGroup="I-43m", ) # define a ZIF L Chemical, we'll need these later too: zl = chempy.Substance.from_formula("C24H38N12O3Zn2") zifLChemical = Chemical( ChemicalID="ZIF-L", ChemicalName="Zeolitic Imidazolate Framework L", ChemicalFormula="C24H38N12O3Zn2", Substance=zl, MolarMass=ureg.Quantity(str(zl.molar_mass())), Density=ureg.Quantity("1.4042 g/cc"), SourceDOI="10.1038/s42004-021-00613-z", SpaceGroup="Cmca", ) # Start with a Experiment exp = Experiment( ID="DACHS", # this also defines the root at which the HDF5 tree starts ExperimentName="DACHS AutoMOF series", Description=""" ## Introduction to DACHS The DACHS (Database for Automation, Characterization and Holistic Synthesis) project aims to create completely traceable experiments, that cover: - syntheses, with all possible details documented - measurements, complete with full metadata on measurement procedures and instrument settings - corrections, traceable corrections applied to the measurements to arrive at analyzable data - analyses, (optionally multiple) reproducible analyses of the corrected data - interpretations of sets of analyses, linked to the synthesis parameters. ## Introduction to the DACHS AutoMOF series The AutoMOF series (AutoMOFs) is a series of experiments within DACHS, aimed at producing reproducible MOF samples through tracking of the synthesis parameters. It is simultaneously used to test the DACHS principles. ## Where to find what Details on the synthesis used for this particular experiment are stored in the /DACHS/Synthesis/Description field. The experimental set-up is described in /DACHS/ExperimentalSetup/Description. The chemicals, including starting compounds, mixtures, and (potential-, target- and final) products are given in the /DACHS/Chemicals group Parameters that might be of interest to the synthesis are stored in /DACHS/Synthesis/DerivedParameters. ## License These DACHS synthesis files are released under a Creative Commons Attribution-ShareAlike 4.0 International License. """, # in this experiment, we are going to use some chemicals. These are defined by the chemicals class. Chemicals=ChemicalsClass( # There is a list of starting compounds in the log file StartingCompounds=ReadStartingCompounds(logFile), Mixtures=[], # Mixtures get filled in later # Then we have the potential products from the synthesis. # Be as thorough as you like here, it will help you later on PotentialProducts=[ Product(ID="ZIF-8", Chemical=zifChemical), Product(ID="ZIF-L", Chemical=zifLChemical), ], # the target product is what you are aiming to get: TargetProduct=Product(ID="TargetProduct", Chemical=zifChemical), # the final product is what you actually got in the end, as evidenced by the evidence. FinalProduct=Product( ID="FinalProduct", Chemical=zifChemical, Evidence=r"Assumed for now ¯\_(ツ)_/¯" ), # mass is set later. ), ) logging.info("defining the Mixtures based on Mixtures of starting compounds") # make a mixture as defined in each of the excel sheets: for filename in solFiles: assert filename.exists(), f"{filename=} does not exist" # read the synthesis logs: df = pd.read_excel( filename, sheet_name="Sheet1", index_col=None, header=0, parse_dates=["Time"], ) assert len(df.SampleNumber.unique()) == 1, logging.error( "no unique mixture ID (sampleNumber) identified in the solution log" ) solutionId = df.SampleNumber.unique()[0] rawLog = readRawMessageLog(filename) mix = Mixture( ID=solutionId, MixtureName="Mixture", Description="", PreparationDate=pd.to_datetime(0, utc=True), # idx, # will be replaced with last timestamp read StorageConditions="RT", ) # new style 20230919, attempting to get rid of synthesisStep aNumber = chempy.util.periodic.atomic_number("Zn") mixIsMetal = False mixIsLinker = False # see known issue on the BAMResearch DACHS Git.. # this is to avoid false matches when using overlapping names: ReagentIDsUsedInSynthesis = [i.Value for i in find_in_log(rawLog, "ReagentID", Highlander=False)] # print(f"{ReagentIDsUsedInSynthesis=}") for reagent in exp.Chemicals.StartingCompounds: RLMList = find_in_log(rawLog, [reagent.ID, "mass of"], Highlander=False, raiseWarning=False) if RLMList is not None: # if the list is not empty: for RLM in RLMList: # add each to the mix # find the preceding message to ensure the reagentID is correct: previousRLM = [i for i in rawLog if i.Index == (RLM.Index - 1)] if not len(previousRLM): break # previousRLM might be empty previousRLM = previousRLM[-1] logging.info(f"{reagent.ID=}, {previousRLM.Value=}, so: {reagent.ID == previousRLM.Value}") if reagent.ID in ReagentIDsUsedInSynthesis: # no idea why I can't also check for this match: # reagent.ID==previousRLM.Value, I get a problem later on mix.add_reagent_to_mix(reag=reagent, ReagentMass=RLM.Quantity) if aNumber in reagent.Chemical.Substance.composition.keys(): mixIsMetal = True if "C4H6N2" in reagent.Chemical.Substance.name: mixIsLinker = True if mixIsMetal: mix.Description = "Metal salt dispersion" if mixIsLinker: mix.Description = "Organic linker dispersion" RLM = find_in_log(rawLog, "mixed together", Highlander=True, Which="first") if RLM is not None: # if this is not empty mix.PreparationDate = RLM.TimeStamp # now we can define the mixture mix.Synthesis = SynthesisClass( ID=f"{solutionId}_Synthesis", Name=f"Preparation of {solutionId}", Description=" ", # SynthesisLog=synth, RawLog=rawLog, ) # enter measured density if available RLM = find_in_log(rawLog, "density determined", Highlander=True, Which="first", raiseWarning=False) if RLM is not None: # if this is not empty mix.Density = RLM.Quantity # override density if calculated is present, as measured was done at 20 degrees, # and calculated is at lab temp: RLM = find_in_log(rawLog, "density calculated", Highlander=True, Which="first", raiseWarning=False) if RLM is not None: # if this is not empty mix.Density = RLM.Quantity # fix the detailed description, clipping off the last ', and ': mix.DetailedDescription = mix.DetailedDescription[:-6] exp.Chemicals.Mixtures += [mix] logging.info("defining the synthesis log") exp.Synthesis = SynthesisClass( ID="Synthesis", Name="MOF standard synthesis in MeOH, room temperature, nominally 20 minute residence time", # description gets added at the end with actual values... RawLog=readRawMessageLog(synFile), ) # After our discussion, we've decided not to focus on including derived parameters just yet. # We still need a few things though. logging.info("Extracting the derived parameters") # minimal derived information: # add the reaction Mixtures to Chemicals.Mixtures # for the start time we need the last "start injection of solution" timestamp StartRLM = find_in_log( exp.Synthesis.RawLog, "Start injection of solution", Highlander=True, Which="last", # return_indices=True, ) # .astimezone('UTC'))#, does this need str-ing? ReactionStart = StartRLM.TimeStamp if StartRLM is not None else pd.to_datetime(0, utc=True) exp.ExperimentName = f"DACHS {rawLog[0].ExperimentID} series" StopRLM = find_in_log( exp.Synthesis.RawLog, "Sample placed in centrifuge", Highlander=True, Which="first", # return_indices=True, ) # .astimezone('UTC'))#, does this need str-ing? ReactionStop = StopRLM.TimeStamp if StopRLM is not None else pd.to_datetime(0, utc=True) # more detailed logging style also indicating sources and descriptions if StartRLM is not None and StopRLM is not None: exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="ReactionTime", ParameterName="Reaction time", Description=( "The time between the start of the second injection into the reaction mixture and the start of" " the centrifugation" ), RawMessages=[StartRLM.Index, StopRLM.Index], Value=(ReactionStop - ReactionStart).total_seconds(), Unit="s", ) ] # if values are not there, I'm no longer making dummy entries as per Ingo's suggestion. keeps the code clean. if amset is not None: sun = amset # override with info in log if present: LogEntry = find_in_log( exp.Synthesis.RawLog, "SetupID", Highlander=True, Which="last", raiseWarning=False, # return_indices=True, ) if LogEntry is not None: if "AMSET" in LogEntry.Value: sun = LogEntry.Value # override if in the log if (LogEntry is None) & (amset is None): logging.error("No AMSET configuration found in log, but also not specified as input argument.") raise SyntaxError logging.info(f"SetupName: {sun}") # At this point, we need the experimental setup as we need the falcon tube.. exp.ExperimentalSetup = readExperimentalSetup(filename=logFile, SetupName=sun) AMSETDescription = exp.ExperimentalSetup.Description # print(exp.ExperimentalSetup) container = [ i for i in exp.ExperimentalSetup.EquipmentList if "falcon tube" in i.EquipmentName.lower() ] # might be empty container = container[-1] if len(container) else None # now we can create a new mixture mix = Mixture( ID="ReactionMix0", MixtureName="Reaction Mixture 0", Description="The MOF synthesis reaction mixture", PreparationDate=ReactionStart, # idx, # last timestamp read StorageConditions="RT", Container=container, ) # to this we need to find the volume and density of which solution for the injections # TODO: do something better to separate solution numbers and their respective volumes. allVolumes = find_in_log(exp.Synthesis.RawLog, ["Solution", "volume set"], Highlander=False) if allVolumes is None: logging.error(f"No injection volume specified in {synFile.stem}") # find calibration factor and offset: Syringe = [i for i in exp.ExperimentalSetup.EquipmentList if i.EquipmentName.lower() == "syringe"] if not len(Syringe): raise Warning("Can't setup Mixture: Syringe not found!") Syringe = Syringe[-1] CalibrationFactor = Syringe.PVs["volume"].CalibrationFactor CalibrationOffset = Syringe.PVs["volume"].CalibrationOffset allSolutions = find_in_log(exp.Synthesis.RawLog, ["Stop", "injection of solution"], Highlander=False) # I don't have the densities yet, so we have to assume something for now for solutionRLM in allSolutions: solutionId = solutionRLM.Value # figure out which volume was used for this by looking at the index: for volRLM in allVolumes: if volRLM.Index < solutionRLM.Index: # the last time we set the volume before injection is the volume used. # WROMG, WROMG, WROMG, WROMG, WROMG, WROMG, WROMG, WROMG, WROMG, WROMG! see, A4_T006 TODO: fix VolumeRLM = volRLM # VolumeRLM = allVolumes[0] DensityOfAdd = getattr( exp.Chemicals.Mixtures[solutionId], "Density" ) # default does not seem to work, still returns None. if DensityOfAdd is None: print(f"no density found for {solutionId}, assuming 0.792 g/cc") DensityOfAdd = ureg.Quantity("0.792 g/cc") # print(f"{DensityOfAdd=}") # print( # f"adding mixture to mix: {exp.Chemicals.Mixtures[solutionId]=}, {VolumeRLM.Quantity=}, # {DensityOfAdd=}" # ) mix.add_mixture_to_mix( exp.Chemicals.Mixtures[solutionId], AddMixtureVolume=( VolumeRLM.Quantity * CalibrationFactor + CalibrationOffset ), # TODO: correction factor should be added in MixtureDensity=DensityOfAdd, ) # clip off the last auto-generated ', and ' from the detailed description mix.DetailedDescription = mix.DetailedDescription[:-6] # Add to the structure. exp.Chemicals.Mixtures += [mix] # calculate the age of solution0 and solution1 into the mix: exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="MetalSolutionAge", ParameterName="Metal Solution Age", Description=( "The time between the preparation of the metal solution and the mixing of the reaction mixture" ), RawMessages=[], Value=( exp.Chemicals.Mixtures[2].PreparationDate - exp.Chemicals.Mixtures[0].PreparationDate ).total_seconds(), Unit="s", ) ] # age of the linker solution exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="LinkerSolutionAge", ParameterName="Metal Solution Age", Description=( "The time between the preparation of the linker solution and the mixing of the reaction mixture" ), RawMessages=[], Value=( exp.Chemicals.Mixtures[2].PreparationDate - exp.Chemicals.Mixtures[1].PreparationDate ).total_seconds(), Unit="s", ) ] # calculate the weight of Product: InitialMass = find_in_log( exp.Synthesis.RawLog, ["empty Falcon tube"], excludeString=["+ dry sample", " lid"], Highlander=True, Which="last", ) FinalMass = find_in_log( exp.Synthesis.RawLog, ["of Falcon tube + dry sample"], excludeString=["lid"], Highlander=True, Which="last" ) # mLocs = np.where(dfMask)[0] logging.debug(f" {InitialMass=}, \n {FinalMass=}") exp.Chemicals.FinalProduct.Mass = FinalMass.Quantity - InitialMass.Quantity # compute theoretical yield: # we need to find out how many moles of metal we have in the previously established reaction mixture logging.debug(f"{len(mix.ComponentList)=}") metMoles = 0 methMoles = 0 linkMoles = 0 aNumber = chempy.util.periodic.atomic_number("Zn") for component in mix.ComponentList: logging.debug(f"{component.Chemical.Substance.composition.keys()=}") if aNumber in component.Chemical.Substance.composition.keys(): # this is the component we're looking for. How many moles of atoms per moles of substance? metalMoles = component.Chemical.Substance.composition[ aNumber ] # just in case we have more metal ions per mole of chem. metMoles += mix.component_moles(MatchComponent=component) * metalMoles if "C4H6N2" in component.Chemical.Substance.name: linkMoles += mix.component_moles(MatchComponent=component) if "CH3OH" in component.Chemical.Substance.name: methMoles += mix.component_moles(MatchComponent=component) TotalMetalMoles = metMoles TotalLinkerMoles = linkMoles # what if =0? -> divBy0 on l444 below TotalMethanolMoles = methMoles logging.info(f"{TotalMetalMoles=}, {TotalLinkerMoles=}, {TotalMethanolMoles=}") # exp.Synthesis.KeyParameters.update({"MetalToLinkerRatio": TotalLinkerMoles / TotalMetalMoles}) # more detailed logging style also indicating sources and descriptions exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="MetalToLinkerRatio", ParameterName="Metal To Linker Ratio", Description=( "The molar ratio between metal ions and linker molecules, as calculated from the solution" " compositions." ), RawMessages=[], Value=(TotalLinkerMoles / TotalMetalMoles).magnitude, Unit=(TotalLinkerMoles / TotalMetalMoles).units, ) ] # exp.Synthesis.KeyParameters.update({"MetalToMethanolRatio": TotalMethanolMoles / TotalMetalMoles}) # more detailed logging style also indicating sources and descriptions exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="MetalToMethanolRatio", ParameterName="Metal To Methanol Ratio", Description=( "The molar ratio between metal ions and solvent (methanol) molecules, as calculated from the" " solution compositions." ), RawMessages=[], Value=(TotalMethanolMoles / TotalMetalMoles).magnitude, Unit=(TotalMethanolMoles / TotalMetalMoles).units, ) ] # The yield is calculated from the target mass versus the actual product mass. exp.Chemicals.TargetProduct.Mass = TotalMetalMoles * exp.Chemicals.TargetProduct.Chemical.MolarMass logging.debug(f"{exp.Chemicals.SynthesisYield=}") exp.Chemicals._storeKeys += ["SynthesisYield"] # maybe later # exp.Synthesis.SourceDOI = "TBD" # TODO: add a default synthesis to Zenodo # We calculate an extra theoretical yield based on the moles of linker: # print(f"{TotalLinkerMoles=}, {exp.Chemicals.TargetProduct.Chemical.MolarMass=}") LinkerBasedProductMass = TotalLinkerMoles / 2 * exp.Chemicals.TargetProduct.Chemical.MolarMass exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="SynthesisYieldLinker", ParameterName="Linker-based synthesis yield", Description="The synthesis yield as calculated based on full conversion of the available linker.", RawMessages=[], Value=(exp.Chemicals.FinalProduct.Mass / LinkerBasedProductMass).magnitude, Unit=(exp.Chemicals.FinalProduct.Mass / LinkerBasedProductMass).units, ) ] # store the room temperature: LogEntry = find_in_log( exp.Synthesis.RawLog, "Environmental temperature", Highlander=True, Which="last", # return_indices=True, ) # exp.Synthesis.KeyParameters.update({"LabTemperature": LogEntry.Quantity}) if LogEntry is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "LabTemperature", "Laboratory temperature", "The temperature of the laboratory as measured about 0.5m above the reaction falcon tubes", LogEntry, ) ] # store the room temperature: LogEntry = find_in_log( exp.Synthesis.RawLog, "Environmental temperature", Highlander=True, Which="last", # return_indices=True, ) # exp.Synthesis.KeyParameters.update({"LabTemperature": LogEntry.Quantity}) if LogEntry is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "LabTemperature", "Laboratory temperature", "The temperature of the laboratory as measured about 0.5m above the reaction falcon tubes", LogEntry, ) ] # store the room temperature: LogEntry = find_in_log( exp.Synthesis.RawLog, "Environmental humidity", Highlander=True, Which="last", # return_indices=True, ) # exp.Synthesis.KeyParameters.update({"LabTemperature": LogEntry.Quantity}) if LogEntry is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "LabHumidity", "Laboratory humidity", "The humidity of the laboratory as measured about 0.5m above the reaction falcon tubes", LogEntry, ) ] # store the room temperature: LogEntry = find_in_log( exp.Synthesis.RawLog, "Environmental pressure", Highlander=True, Which="last", # return_indices=True, ) # exp.Synthesis.KeyParameters.update({"LabTemperature": LogEntry.Quantity}) if LogEntry is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "LabPressure", "Laboratory pressure", "The ambient pressure of the laboratory as measured about 0.5m above the reaction falcon tubes", LogEntry, ) ] # New additions 2024-09-18 # store the wash solvvent volume: LogEntry = find_in_log( exp.Synthesis.RawLog, "Wash volume", Highlander=True, Which="last", # return_indices=True, ) # exp.Synthesis.KeyParameters.update({"LabTemperature": LogEntry.Quantity}) if LogEntry is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "WashSolventVolume", "Wash solvent volume", "The volume of the wash solvent used to clean the final product", LogEntry, ) ] # store the wash solvvent: LogEntry = find_in_log( exp.Synthesis.RawLog, "Wash solution", Highlander=True, Which="last", # return_indices=True, ) # washSolventID = [i.Chemical.ChemicalName for i in exp.Chemicals.StartingCompounds if i.ID == LogEntry.Value] # if len(washSolventID) == 1: # washSolventID = washSolventID[0] # else: # washSolventID = None # exp.Synthesis.KeyParameters.update({"LabTemperature": LogEntry.Quantity}) if LogEntry is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "WashSolvent", "Wash solvent", "The wash solvent used to clean the final product", LogEntry, ) ] # store the number of washes by counting the number of centrifugations -1 LogEntry = find_in_log( exp.Synthesis.RawLog, "Sample placed in centrifuge", Highlander=False, # return_indices=True, ) if LogEntry is not None: exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="NumberOfWashes", ParameterName="Number of washes", Description="The number of times the product has been washed after the initial centrifugation.", RawMessages=[i.Index for i in LogEntry], Value=len(LogEntry) - 1, Unit=ureg.dimensionless, ) ] # injection speed: LogEntry = find_in_log( exp.Synthesis.RawLog, ["Solution", "rate set"], Highlander=True, Which="last", # return_indices=True, ) # exp.Synthesis.KeyParameters.update({"InjectionSpeed": LogEntry.Quantity}) if LogEntry is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "InjectionSpeed", "Injection Speed", "The speed at which the second reactant was added to the reaction mixture", LogEntry, ) ] # exp.Synthesis.KeyParameters.update({"SynthesisYield": exp.Chemicals.SynthesisYield}) exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="SynthesisYield", ParameterName="Synthesis Yield", Description="The yield of the synthesis as calculated from the final product mass", RawMessages=[InitialMass.Index, FinalMass.Index], Quantity=exp.Chemicals.SynthesisYield, Value=exp.Chemicals.SynthesisYield.magnitude, Unit=exp.Chemicals.SynthesisYield.units, ) ] # add notes to the KeyParameters: noteCounter = 0 noteList = find_in_log( exp.Synthesis.RawLog, "Note", Highlander=False, # Which="last", # return_indices=True, raiseWarning=False, ) if noteList is not None: for note in noteList: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( f"Note{noteCounter}", f"Note {noteCounter}", "An operator note or flag as pertaining to the synthesis", note, ) ] noteCounter += 1 # lastly, we can prune all the unused reagents from StartingCompounds: exp.Chemicals.StartingCompounds = [item for item in exp.Chemicals.StartingCompounds if item.Used] # Finally, we add the text description: # TODO: specify the order and delay between the solution injections # stirring speed RPM StirrerSpeed = find_in_log( exp.Synthesis.RawLog, "Set stirring speed", Highlander=True, Which="last", # return_indices=True, ) if StirrerSpeed is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "StirrerSpeed", "Stirring plate speed", "The speed of the stirring plate under the Falcon tube", StirrerSpeed, ) ] CentrifugeSpeed = find_in_log( exp.Synthesis.RawLog, ["Sample", "placed in centrifuge"], Highlander=True, Which="last", ) if CentrifugeSpeed is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry("CentrifugeSpeed", "Centrifuge Speed", "The speed of centrifugation", CentrifugeSpeed) ] CentrifugeDuration = find_in_log( exp.Synthesis.RawLog, ["Centrifuge", "set time"], Highlander=True, Which="last", ) if CentrifugeDuration is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "CentrifugeDuration", "Centrifuge Duration", "The duration of centrifugation", CentrifugeDuration ) ] OvenStop = find_in_log( exp.Synthesis.RawLog, ["Sample", "removed from oven"], Highlander=True, Which="last", ) OvenStart = find_in_log( exp.Synthesis.RawLog, ["Sample", "oven"], Highlander=True, Which="first", ) if OvenStart is not None: exp.Synthesis.DerivedParameters += [ DParFromLogEntry( "OvenTemperature", "Oven Temperature", "The setpoint temperature of the drying oven", OvenStart ) ] if OvenStart is not None and OvenStop is not None: exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="ForcedDryingDuration", ParameterName="Forced Drying Duration", Description=( "The duration of the oven drying, i.e. between placing the sample in the oven and taking it" " out again" ), RawMessages=[OvenStart.Index, OvenStop.Index], Value=(OvenStop.TimeStamp - OvenStart.TimeStamp).total_seconds(), Unit="s", ) ] StirrerBarList = [i for i in exp.ExperimentalSetup.EquipmentList if ("Stirrer Bar" in i.EquipmentName)] StirrerBar = StirrerBarList[0] if len(StirrerBarList) else None if StirrerBar is not None: # len(StirrerBarList)>0: # StirrerBar = StirrerBarList[0] exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="StirrerBarModel", ParameterName="Stirrer Bar Model", Description=( f"The stirrer bar is from {StirrerBar.Manufacturer}, model: {StirrerBar.ModelName}, model" f" number {StirrerBar.ModelNumber}." ), RawMessages=[StirrerBar.ID], Value=StirrerBar.ModelName, ) ] # Injection orders, we'll just read this from the SampleID letter... SIDLetter = exp.Synthesis.RawLog[0].SampleID[0] assert SIDLetter in ["T", "L", "M", "H", "P"] if SIDLetter == "T": OrderDescription = "The two solutions were added simultaneously." elif SIDLetter == "L": OrderDescription = ( "The linker solution was added in a pre-injection step, after which the metal solution was added at" " the specified injection rate." ) elif SIDLetter == "M": OrderDescription = ( "The metal solution was added in a pre-injection step, after which the linker solution was added at" " the specified injection rate." ) elif SIDLetter == "H": OrderDescription = ( "The metal solution was added in a pre-injection step, after which the linker solution was added" " through a hand pour as fast as possible. Both solutions were prepared using the syringe injector." ) elif SIDLetter == "P": OrderDescription = ( "The metal solution was added in a pre-injection step, after which the linker solution was added" " through a hand pour as fast as possible. Both solutions were prepared using a pipette." ) else: OrderDescription = "" ReactionVesselModel = container # we've extracted this before already. StirrerBarModel = StirrerBar # ibid. # get the stirrer plate: MatchList = [i for i in exp.ExperimentalSetup.EquipmentList if ("Stirring Plate" in i.EquipmentName)] StirrerPlateModel = MatchList[0] if len(MatchList) else None # override FinalMass with the actual final mass: FinalMass = DerivedParameter( ID="FinalMass", ParameterName="Final Mass", Description="The mass of the final product as measured after drying", RawMessages=[], Quantity=exp.Chemicals.FinalProduct.Mass, Value=exp.Chemicals.FinalProduct.Mass.magnitude, Unit=exp.Chemicals.FinalProduct.Mass.units, ) exp.Synthesis.DerivedParameters += [FinalMass] # defaults for text generation: DPars = { "ReactionTime": ureg.Quantity(-1.0, "s"), "MetalSolutionAge": ureg.Quantity(-1.0, "s"), "LinkerSolutionAge": ureg.Quantity(-1.0, "s"), "MetalToLinkerRatio": ureg.Quantity(-1.0, "dimensionless"), "MetalToMethanolRatio": ureg.Quantity(-1.0, "dimensionless"), "SynthesisYieldLinker": ureg.Quantity(-1.0, "dimensionless"), "LabTemperature": ureg.Quantity(-1.0, "degC"), "InjectionSpeed": ureg.Quantity(-1.0, "ml/min"), "SynthesisYield": ureg.Quantity(-1.0, "dimensionless"), "CentrifugeSpeed": ureg.Quantity(-1.0, "rpm"), "CentrifugeDuration": ureg.Quantity(-1.0, "s"), "OvenTemperature": ureg.Quantity(-1.0, "degC"), "ForcedDryingDuration": ureg.Quantity(-1.0, "s"), "StirrerSpeed": ureg.Quantity(-1.0, "rpm"), # "StirrerBarModel": "Unknown", "AMSETDescription": AMSETDescription, "OrderDescription": "Unknown injection order", } [DPars.update({i.ID: i}) for i in exp.Synthesis.DerivedParameters if isinstance(i, DerivedParameter)] Mixes = [i for i in exp.Chemicals.Mixtures if isinstance(i, Mixture)] ReactionMix = [i for i in Mixes if i.ID == "ReactionMix0"][0] S0Mix = [i for i in Mixes if i.ID == "Solution0"][0] S1Mix = [i for i in Mixes if i.ID == "Solution1"][0] addKeys = [ "OrderDescription", "S0Mix", "S1Mix", "ReactionMix", "OvenStop", "ReactionVesselModel", "StirrerBarModel", "StirrerPlateModel", "FinalMass", ] exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="ChemicalCost", ParameterName="Cost of Chemicals", Description=( "The cost of the chemicals used in the synthesis, as calculated from the base " "chemical prices and the quantities used in this reaction." ), RawMessages=[], Quantity=ReactionMix.total_price, Value=ReactionMix.total_price.magnitude, Unit=ReactionMix.total_price.units, ) ] # a couple more requests by Glen for duplication of parameters in the derivedparameters: exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="TotalReactionMass", ParameterName="Total Reactionmixture Mass", Description=( "The total mass of the reaction mixture in the falcon tube, as calculated from " "the masses of the individual components specified in Chemicals/Mixtures/ReactionMix0." ), RawMessages=[], Quantity=ReactionMix.total_mass, Value=ReactionMix.total_mass.magnitude, Unit=ReactionMix.total_mass.units, ) ] # a couple more requests by Glen for duplication of parameters in the derivedparameters: exp.Synthesis.DerivedParameters += [ DerivedParameter( ID="TotalReactionMoles", ParameterName="Total Reactionmixture Moles", Description=( "The total amount of moles that comprises the reaction mixture in the falcon tube, " "as calculated from the moles of the individual components specified in " "Chemicals/Mixtures/ReactionMix0." ), RawMessages=[], Quantity=ReactionMix.total_moles(), Value=ReactionMix.total_moles().magnitude, Unit=ReactionMix.total_moles().units, ) ] # print(f"Price of reaction mix: {ReactionMix.total_price:.2f~P}") for key in addKeys: if key in locals(): DPars[key] = locals()[key] else: logging.warning(f"{key=} not found in locals, cannot be added to DPars.") for key in DPars: if DPars[key] == []: # empty list issue logging.warning(f"{key=} in DPars is an empty list, probably missing from {synFile.stem}") # print(DPars.keys()) # we read the template text per automof, and then format it using the information in DPars. descText = "" # print(f"Reading text from file: {Path(synFile.parent, 'SynthesisTemplate.txt').read_text()}") try: descText = (Path(synFile.parent) / "SynthesisTemplate.txt").read_text().format(**DPars) except Exception as e: logging.warning("Issue encountered when rendering text from template:") logging.exception(e) # add notes if they exist to the end of the synthesis text. if noteList is not None: for Notei, Note in enumerate(noteList): descText += f" Note {Notei}: {Note.Value}" exp.Synthesis.Description = descText return exp