Spaces:
Running
Running
viviandlin commited on
Commit ·
33ba306
0
Parent(s):
Initial release of NucleoSpec
Browse files- .gitattributes +35 -0
- .github/workflows/sync_hf.yml +46 -0
- .gitignore +1 -0
- Dockerfile +29 -0
- README.md +103 -0
- core/__init__.py +0 -0
- core/analyzer.py +0 -0
- dna_silver_webapp.py +0 -0
- environment_hf.yml +15 -0
- lib/pythoms/__init__.py +1 -0
- lib/pythoms/colour.py +240 -0
- lib/pythoms/mass_abbreviations.py +59 -0
- lib/pythoms/mass_dictionaries.py +0 -0
- lib/pythoms/molecule.py +2117 -0
- lib/pythoms/progress.py +172 -0
- lib/pythoms/scripttime.py +146 -0
- lib/pythoms/senko_charge_assignment.py +874 -0
- lib/pythoms/spectrum.py +900 -0
- lib/pythoms/tome.py +1237 -0
- sample_data/GG208.txt +0 -0
- templates/index.html +0 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/sync_hf.yml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
sync:
|
| 9 |
+
runs-on: ubuntu-latest
|
| 10 |
+
steps:
|
| 11 |
+
- uses: actions/checkout@v4
|
| 12 |
+
with:
|
| 13 |
+
fetch-depth: 0
|
| 14 |
+
lfs: true
|
| 15 |
+
|
| 16 |
+
- name: Push to HF Space
|
| 17 |
+
env:
|
| 18 |
+
HF_TOKEN: ${{ secrets.HF }}
|
| 19 |
+
run: |
|
| 20 |
+
git clone https://viviandlin:$HF_TOKEN@huggingface.co/spaces/copplab/NucleoSpec hf_repo || {
|
| 21 |
+
mkdir hf_repo && cd hf_repo && git init && git branch -M main
|
| 22 |
+
git remote add origin https://viviandlin:$HF_TOKEN@huggingface.co/spaces/copplab/NucleoSpec
|
| 23 |
+
cd ..
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
cd hf_repo
|
| 27 |
+
git config user.email "vivian891110@gmail.com"
|
| 28 |
+
git config user.name "viviandlin"
|
| 29 |
+
|
| 30 |
+
# Remove old files (except .git) and copy fresh ones
|
| 31 |
+
find . -maxdepth 1 ! -name '.git' ! -name '.' -exec rm -rf {} +
|
| 32 |
+
cp ../dna_silver_webapp.py .
|
| 33 |
+
cp ../Dockerfile .
|
| 34 |
+
cp ../environment_hf.yml .
|
| 35 |
+
# Prepend HF Space YAML frontmatter (kept out of GitHub README)
|
| 36 |
+
printf '%s\n' '---' 'title: NucleoSpec' 'emoji: 🔬' 'colorFrom: blue' 'colorTo: purple' 'sdk: docker' 'app_port: 7860' 'pinned: false' 'license: cc-by-nc-4.0' 'short_description: ESI-MS analysis of nucleic acid-silver complexes' '---' '' > README.md
|
| 37 |
+
cat ../README.md >> README.md
|
| 38 |
+
cp -r ../core/ core/
|
| 39 |
+
cp -r ../lib/ lib/
|
| 40 |
+
cp -r ../templates/ templates/
|
| 41 |
+
cp -r ../sample_data/ sample_data/
|
| 42 |
+
|
| 43 |
+
echo "${{ github.sha }}" > .last_sync
|
| 44 |
+
git add -A
|
| 45 |
+
git commit -m "Sync from GitHub: ${{ github.event.head_commit.message }}"
|
| 46 |
+
git push origin main
|
.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
core/.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM continuumio/miniconda3:latest
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install g++ (needed to build IsoSpecPy C++ extension)
|
| 6 |
+
RUN apt-get update && apt-get install -y g++ && rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
# Install conda dependencies (including RDKit from conda-forge)
|
| 9 |
+
COPY environment_hf.yml .
|
| 10 |
+
RUN conda env create -f environment_hf.yml && conda clean -afy
|
| 11 |
+
|
| 12 |
+
# Activate conda env for all subsequent commands
|
| 13 |
+
SHELL ["conda", "run", "-n", "dna_mass_spec", "/bin/bash", "-c"]
|
| 14 |
+
|
| 15 |
+
# Copy application code
|
| 16 |
+
COPY dna_silver_webapp.py .
|
| 17 |
+
COPY core/ core/
|
| 18 |
+
COPY lib/ lib/
|
| 19 |
+
COPY templates/index.html templates/
|
| 20 |
+
COPY sample_data/ sample_data/
|
| 21 |
+
|
| 22 |
+
# HF Spaces expects port 7860
|
| 23 |
+
ENV PORT=7860
|
| 24 |
+
ENV SECRET_KEY=auto
|
| 25 |
+
ENV FLASK_ENV=production
|
| 26 |
+
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
CMD ["conda", "run", "--no-capture-output", "-n", "dna_mass_spec", "python", "dna_silver_webapp.py"]
|
README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NucleoSpec
|
| 2 |
+
|
| 3 |
+
**Nucleic Acid-Silver Complex & Cluster Analyzer**
|
| 4 |
+
|
| 5 |
+
A web-based application for analyzing nucleic acid (DNA/XNA)-silver complexes and nanoclusters from mass spectrometry data.
|
| 6 |
+
|
| 7 |
+
## Project Structure
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
NucleoSpec/
|
| 11 |
+
├── dna_silver_webapp.py # Main Flask application
|
| 12 |
+
├── core/
|
| 13 |
+
│ └── analyzer.py # DNASilverAnalyzer class (analysis logic)
|
| 14 |
+
├── lib/
|
| 15 |
+
│ └── pythoms/ # PythoMS library (isotope calculations)
|
| 16 |
+
├── templates/ # HTML templates
|
| 17 |
+
├── sample_data/ # Example spectrum files
|
| 18 |
+
└── environment_hf.yml # HuggingFace deployment environment
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## Installation
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
conda env create -f environment.yml
|
| 25 |
+
conda activate dna_mass_spec
|
| 26 |
+
python dna_silver_webapp.py
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
Open http://localhost:8080 in browser.
|
| 30 |
+
|
| 31 |
+
## Analysis Modes
|
| 32 |
+
|
| 33 |
+
### DNA-Ag<sub>N</sub> Mode
|
| 34 |
+
For single-stranded DNA-silver nanoclusters.
|
| 35 |
+
- Enter DNA sequence using A, T, G, C bases
|
| 36 |
+
- Automatically calculates molecular composition
|
| 37 |
+
- Uses N₀/Qcl framework for cluster characterization
|
| 38 |
+
|
| 39 |
+
### Ag(I)-DNA/XNA Complex Mode
|
| 40 |
+
For double-stranded DNA or DNA/XNA hybrids.
|
| 41 |
+
- **DNA Complex**: Enter two DNA sequences (Strand 1 and Strand 2)
|
| 42 |
+
- **XNA Complex**: Check "Use XNA" and enter two molecular formulas
|
| 43 |
+
- Formulas are automatically combined by adding atoms
|
| 44 |
+
|
| 45 |
+
### XNA-Ag<sub>N</sub> Mode
|
| 46 |
+
For custom xeno nucleic acids (TNA, PNA, LNA, etc.).
|
| 47 |
+
- Enter XNA name for identification
|
| 48 |
+
- Enter complete molecular formula (e.g., C<sub>100</sub>H<sub>120</sub>N<sub>40</sub>O<sub>60</sub>P<sub>10</sub>)
|
| 49 |
+
- Optionally use JSME structure drawer to get formula
|
| 50 |
+
|
| 51 |
+
## Workflow
|
| 52 |
+
|
| 53 |
+
1. **Select Mode** - Choose DNA-Ag<sub>N</sub>, Ag(I)-DNA/XNA Complex, or XNA-Ag<sub>N</sub> from the mode selector
|
| 54 |
+
2. **Upload Spectrum** - Click "Choose File" and select your mass spectrum file
|
| 55 |
+
3. **Enter Information** - Provide DNA sequence or XNA formula based on selected mode
|
| 56 |
+
4. **Apply Settings** - Click the "Apply" button to confirm your settings
|
| 57 |
+
5. **Analyze Peaks** - Click any peak in the spectrum to find matching compositions
|
| 58 |
+
6. **Compare Results** - Toggle checkboxes to overlay theoretical isotope patterns on the experimental data
|
| 59 |
+
|
| 60 |
+
## File Format
|
| 61 |
+
|
| 62 |
+
Spectrum files should be two-column format (tab or comma separated):
|
| 63 |
+
```
|
| 64 |
+
m/z intensity
|
| 65 |
+
1000.123 45678.9
|
| 66 |
+
1000.456 56789.0
|
| 67 |
+
1001.234 67890.1
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
Supported formats: .txt, .csv
|
| 71 |
+
|
| 72 |
+
## Output Fields
|
| 73 |
+
|
| 74 |
+
| Field | Description |
|
| 75 |
+
|-------|-------------|
|
| 76 |
+
| Formula | Neutral molecular formula of the cluster |
|
| 77 |
+
| Ion Formula | Charged species formula (can be copied) |
|
| 78 |
+
| n<sub>Ag <sub>| Number of silver atoms in the cluster |
|
| 79 |
+
| N₀ | Number of effective valence electrons|
|
| 80 |
+
| Qcl | Charge of inorganic core |
|
| 81 |
+
| z | Charge state of the detected ion |
|
| 82 |
+
| ΔX₀ | Difference between experimental and theoretical centroid (lower = better match). Primary criterion for Best Fit selection. |
|
| 83 |
+
| Pattern similarity | Mean of cosine similarity and Pearson correlation between experimental and theoretical isotope envelopes (0–1). Confidence indicator: ▲ > 0.8 high, ○ 0.5–0.8 moderate, ▽ < 0.5 low |
|
| 84 |
+
|
| 85 |
+
## Features
|
| 86 |
+
|
| 87 |
+
- **Charge Detection** - Automatic charge state determination from isotope spacing
|
| 88 |
+
- **Isotope Pattern Matching** - Compare experimental peaks with theoretical patterns
|
| 89 |
+
- **Adduct Support** - Account for common adducts (NH₄⁺, Na⁺, Cl⁻) plus user-defined custom adducts
|
| 90 |
+
- **Structure Drawing** - JSME molecule editor for drawing bioconjugate structures
|
| 91 |
+
- **Data Export** - Download theoretical spectra as CSV files
|
| 92 |
+
|
| 93 |
+
## Technical Details
|
| 94 |
+
|
| 95 |
+
- **Backend:** Python 3.12, Flask, NumPy, SciPy
|
| 96 |
+
- **Frontend:** HTML5, JavaScript, Plotly.js, JSME
|
| 97 |
+
- **Libraries:** PythoMS, IsoSpecPy
|
| 98 |
+
|
| 99 |
+
## Support
|
| 100 |
+
|
| 101 |
+
- **Developer:** I-Hsin (Vivian) Lin
|
| 102 |
+
- **Email:** ihl1@uci.edu
|
| 103 |
+
- **Lab:** Copp Lab, University of California, Irvine
|
core/__init__.py
ADDED
|
File without changes
|
core/analyzer.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dna_silver_webapp.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
environment_hf.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: dna_mass_spec
|
| 2 |
+
channels:
|
| 3 |
+
- conda-forge
|
| 4 |
+
- defaults
|
| 5 |
+
dependencies:
|
| 6 |
+
- python=3.12
|
| 7 |
+
- flask=3.0.0
|
| 8 |
+
- numpy=1.26.4
|
| 9 |
+
- scipy=1.16.3
|
| 10 |
+
- sympy=1.14.0
|
| 11 |
+
- rdkit
|
| 12 |
+
- matplotlib
|
| 13 |
+
- pip
|
| 14 |
+
- pip:
|
| 15 |
+
- IsoSpecPy==2.2.1
|
lib/pythoms/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = '1.6.3'
|
lib/pythoms/colour.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
IGNORE:
|
| 3 |
+
takes an input string, tuple, or list and attempts to interpret it as a colour
|
| 4 |
+
|
| 5 |
+
new:
|
| 6 |
+
several methods were rewritten to be less restrictive
|
| 7 |
+
updated docstring
|
| 8 |
+
added cmyk support (both to and from)
|
| 9 |
+
added printdetails method for easy colour conversion
|
| 10 |
+
made it so hex input does not need a '#'
|
| 11 |
+
---2.1---
|
| 12 |
+
---2.2
|
| 13 |
+
IGNORE
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Colour(object):
|
| 18 |
+
def __init__(self, c, rgb_scale=255, cmyk_scale=100):
|
| 19 |
+
"""
|
| 20 |
+
Takes an input colour string and stores several colour properties of that colour
|
| 21 |
+
|
| 22 |
+
**Parameters**
|
| 23 |
+
|
| 24 |
+
c: *string* or *tuple*
|
| 25 |
+
Input string (predefined colour or hex code) or R,G,B tuple.
|
| 26 |
+
|
| 27 |
+
rgb_scale: *integer*
|
| 28 |
+
The maximum value of the RGB scale for this object. Default is 255, implying
|
| 29 |
+
RGB values between 0-255.
|
| 30 |
+
|
| 31 |
+
cmyk_scale: *integer*
|
| 32 |
+
The maximum value of the CMYK scale. Currently unused
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
**Examples**
|
| 36 |
+
|
| 37 |
+
::
|
| 38 |
+
|
| 39 |
+
>>> col = Colour((89,89,89))
|
| 40 |
+
>>> col.print_details()
|
| 41 |
+
Colour (89, 89, 89)
|
| 42 |
+
RGB: 89, 89, 89
|
| 43 |
+
RGB (0-1): 0.349, 0.349, 0.349
|
| 44 |
+
CMYK: 0.0, 0.0, 0.0, 65.1
|
| 45 |
+
hex: #595959
|
| 46 |
+
|
| 47 |
+
>>> col.mpl # matplotlib RGB format (floats between 0 and 1)
|
| 48 |
+
(0.34901960784313724, 0.34901960784313724, 0.34901960784313724)
|
| 49 |
+
|
| 50 |
+
>>> col2 = Colour('#A8E5B0')
|
| 51 |
+
>>> col2.rgb
|
| 52 |
+
(168, 229, 176)
|
| 53 |
+
>>> col2.cmyk
|
| 54 |
+
(26.637554585152838, 0.0, 23.144104803493455, 10.196078431372547)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
**Predefined Colours**
|
| 58 |
+
|
| 59 |
+
b: blue
|
| 60 |
+
r: red
|
| 61 |
+
g: olive green
|
| 62 |
+
p: purple
|
| 63 |
+
o: orange
|
| 64 |
+
a: aqua
|
| 65 |
+
k: black
|
| 66 |
+
|
| 67 |
+
d_: dark colour (ex. 'db' = dark blue)
|
| 68 |
+
l_: light colour (ex. 'lb' = light blue)
|
| 69 |
+
|
| 70 |
+
"""
|
| 71 |
+
self.c = c
|
| 72 |
+
self.rgb_scale = float(rgb_scale)
|
| 73 |
+
self.cmyk_scale = float(cmyk_scale)
|
| 74 |
+
self.ty = type(self.c)
|
| 75 |
+
if self.ty == str: # interpret as string
|
| 76 |
+
self.interpretstring(c)
|
| 77 |
+
elif self.ty == tuple or self.ty == list: # interpret as list or tuple
|
| 78 |
+
if len(self.c) == 3:
|
| 79 |
+
self.validatergb(self.c)
|
| 80 |
+
self.rgb = self.c
|
| 81 |
+
self.cmyk = self.rgb_to_cmyk(self.rgb)
|
| 82 |
+
elif len(self.c) == 4:
|
| 83 |
+
self.validatecmyk(self.c)
|
| 84 |
+
self.cmyk = self.c
|
| 85 |
+
self.rgb = self.cmyk_to_rgb(self.cmyk)
|
| 86 |
+
self.hex = self.rgb_to_hex(self.rgb)
|
| 87 |
+
self.mpl = self.mplcolour(self.rgb) # generate matplotlib colour tuple
|
| 88 |
+
self.rgb_to_others() # calculate other values
|
| 89 |
+
|
| 90 |
+
def __str__(self):
|
| 91 |
+
return 'Colour {}'.format(self.c)
|
| 92 |
+
|
| 93 |
+
def __repr__(self):
|
| 94 |
+
return '{}({})'.format(self.__class__.__name__, self.c)
|
| 95 |
+
|
| 96 |
+
def hex_to_rgb(self, string):
|
| 97 |
+
"""
|
| 98 |
+
converts hex colour to tuple
|
| 99 |
+
snagged from http://stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa
|
| 100 |
+
"""
|
| 101 |
+
string = string.lstrip('#') # remove hash if present
|
| 102 |
+
lv = len(string)
|
| 103 |
+
if lv % 3 == 0:
|
| 104 |
+
out = []
|
| 105 |
+
for i in range(0, lv, lv // 3):
|
| 106 |
+
out.append(int(string[i:i + lv // 3], 16)) # append integer in base 16
|
| 107 |
+
return tuple(out)
|
| 108 |
+
# return tuple(int(string[i:i + lv // 3], 16) for i in range(0, lv, lv // 3)) #list-comprehension version
|
| 109 |
+
else:
|
| 110 |
+
raise ValueError('The provided colour string "%s" could not be interpreted as a colour.\n%s' % (
|
| 111 |
+
string, self.__class__.__doc__))
|
| 112 |
+
|
| 113 |
+
def cmyk_to_rgb(self, tup):
|
| 114 |
+
"""converts cmyk tuple to rgb"""
|
| 115 |
+
# set values and normalize
|
| 116 |
+
c, m, y, k = tup
|
| 117 |
+
c /= self.cmyk_scale
|
| 118 |
+
m /= self.cmyk_scale
|
| 119 |
+
y /= self.cmyk_scale
|
| 120 |
+
k /= self.cmyk_scale
|
| 121 |
+
|
| 122 |
+
# convert to RGB and scale
|
| 123 |
+
r = int(round(self.rgb_scale * (1.0 - c) * (1.0 - k), 0))
|
| 124 |
+
g = int(round(self.rgb_scale * (1.0 - m) * (1.0 - k), 0))
|
| 125 |
+
b = int(round(self.rgb_scale * (1.0 - y) * (1.0 - k), 0))
|
| 126 |
+
return r, g, b
|
| 127 |
+
|
| 128 |
+
def interpretstring(self, string):
|
| 129 |
+
"""interprets a string as a colour"""
|
| 130 |
+
colourdict = {
|
| 131 |
+
'b': (79, 129, 189),
|
| 132 |
+
'r': (192, 80, 77),
|
| 133 |
+
'g': (155, 187, 89),
|
| 134 |
+
'p': (128, 100, 162),
|
| 135 |
+
'a': (75, 172, 198),
|
| 136 |
+
'o': (247, 150, 70),
|
| 137 |
+
'lb': (149, 179, 215),
|
| 138 |
+
'lr': (217, 150, 148),
|
| 139 |
+
'lg': (195, 214, 155),
|
| 140 |
+
'lp': (179, 162, 199),
|
| 141 |
+
'lo': (147, 205, 221),
|
| 142 |
+
'la': (250, 192, 144),
|
| 143 |
+
'db': (54, 96, 146),
|
| 144 |
+
'dr': (99, 37, 35),
|
| 145 |
+
'dg': (79, 98, 40),
|
| 146 |
+
'dp': (64, 49, 82),
|
| 147 |
+
'do': (33, 89, 104),
|
| 148 |
+
'da': (152, 72, 7),
|
| 149 |
+
'k': (0, 0, 0),
|
| 150 |
+
}
|
| 151 |
+
if string in colourdict:
|
| 152 |
+
self.hex = self.rgb_to_hex(colourdict[string])
|
| 153 |
+
self.rgb = colourdict[string]
|
| 154 |
+
else:
|
| 155 |
+
self.rgb = self.hex_to_rgb(string)
|
| 156 |
+
self.hex = string
|
| 157 |
+
self.cmyk = self.rgb_to_cmyk(self.rgb)
|
| 158 |
+
|
| 159 |
+
def mplcolour(self, c):
|
| 160 |
+
"""converts 0-255 integers to 0-1 floats used by matplotlib"""
|
| 161 |
+
out = []
|
| 162 |
+
for val in c: # normalize output to 255
|
| 163 |
+
out.append(val / self.rgb_scale)
|
| 164 |
+
return tuple(out)
|
| 165 |
+
|
| 166 |
+
def printdetails(self):
|
| 167 |
+
"""prints the details of the object"""
|
| 168 |
+
import sys
|
| 169 |
+
sys.stdout.write('Colour %s\n' % str(self.c))
|
| 170 |
+
sys.stdout.write('RGB: %d, %d, %d\n' % self.rgb)
|
| 171 |
+
sys.stdout.write('RGB (0-1): %.3f, %.3f, %.3f\n' % self.mpl)
|
| 172 |
+
sys.stdout.write('CMYK: %.1f, %.1f, %.1f, %.1f\n' % self.cmyk)
|
| 173 |
+
# sys.stdout.write('HLS: %.3f, %.3f, %.3f\n' %self.hls)
|
| 174 |
+
# sys.stdout.write('HSV: %.3f, %.3f, %.3f\n' %self.hsv)
|
| 175 |
+
# sys.stdout.write('YIQ: %.3f, %.3f, %.3f\n' %self.yiq)
|
| 176 |
+
sys.stdout.write('hex: %s' % self.hex)
|
| 177 |
+
|
| 178 |
+
def rgb_to_cmyk(self, tup):
|
| 179 |
+
"""converts rgb tuple to cmyk"""
|
| 180 |
+
if sum(tup) == 0: # black
|
| 181 |
+
return 0, 0, 0, self.cmyk_scale
|
| 182 |
+
|
| 183 |
+
# set values and normalize
|
| 184 |
+
r, g, b = tup
|
| 185 |
+
r /= self.rgb_scale
|
| 186 |
+
g /= self.rgb_scale
|
| 187 |
+
b /= self.rgb_scale
|
| 188 |
+
|
| 189 |
+
# extract CMYK values
|
| 190 |
+
k = 1 - max(r, g, b)
|
| 191 |
+
c = (1 - r - k) / (1 - k)
|
| 192 |
+
m = (1 - g - k) / (1 - k)
|
| 193 |
+
y = (1 - b - k) / (1 - k)
|
| 194 |
+
|
| 195 |
+
# scale and return
|
| 196 |
+
return c * self.cmyk_scale, m * self.cmyk_scale, y * self.cmyk_scale, k * self.cmyk_scale
|
| 197 |
+
|
| 198 |
+
def rgb_to_hex(self, tup):
|
| 199 |
+
"""converts RGB tuple to hex code"""
|
| 200 |
+
return '#%02x%02x%02x' % tup
|
| 201 |
+
|
| 202 |
+
def rgb_to_others(self):
|
| 203 |
+
"""
|
| 204 |
+
converts RGB tuple to:
|
| 205 |
+
HLS (Hue Lightness Saturation)
|
| 206 |
+
HSV (Hue Saturation Value)
|
| 207 |
+
YIQ"""
|
| 208 |
+
import colorsys
|
| 209 |
+
r, g, b = [i / self.rgb_scale for i in self.rgb]
|
| 210 |
+
self.hls = colorsys.rgb_to_hls(r, g, b)
|
| 211 |
+
self.hsv = colorsys.rgb_to_hsv(r, g, b)
|
| 212 |
+
self.yiq = colorsys.rgb_to_yiq(r, g, b)
|
| 213 |
+
|
| 214 |
+
def validatecmyk(self, tup):
|
| 215 |
+
"""checks that values in a CMYK tuple are valid"""
|
| 216 |
+
for val in tup:
|
| 217 |
+
if type(val) != int and type(val) != float:
|
| 218 |
+
raise ValueError('One of the values in the CMYK colour tuple is not a number (%s).\n%s' % (
|
| 219 |
+
str(val), self.__class__.__doc__))
|
| 220 |
+
if val > self.cmyk_scale or val < 0:
|
| 221 |
+
raise ValueError(
|
| 222 |
+
'One of the values in the CMYK colour tuple (%s) is outside of the valid range (0-%d)\n%s' % (
|
| 223 |
+
str(val), self.cmyk_scale, self.__class__.__doc__))
|
| 224 |
+
|
| 225 |
+
def validatergb(self, tup):
|
| 226 |
+
"""checks that values in an RGB tuple are valid"""
|
| 227 |
+
for val in tup:
|
| 228 |
+
if type(val) != int:
|
| 229 |
+
raise ValueError(
|
| 230 |
+
'One of the values in the RGB colour tuple is not an integer ("%s").\nPlease correct the value.\n%s' % (
|
| 231 |
+
str(val), self.__class__.__doc__))
|
| 232 |
+
elif val > self.rgb_scale or val < 0:
|
| 233 |
+
raise ValueError(
|
| 234 |
+
'One of the values in the RGB colour tuple (%s) is outside of the valid RGB range of 0-%d.\n%s' % (
|
| 235 |
+
str(val), self.rgb_scale, self.__class__.__doc__))
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
if __name__ == '__main__':
|
| 239 |
+
col = Colour((89, 89, 89))
|
| 240 |
+
col.printdetails()
|
lib/pythoms/mass_abbreviations.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dictionary of common abbreviations used in chemical formulas
|
| 3 |
+
A new 'element' is triggered by an upper case letter in the MolecularFormula class
|
| 4 |
+
Additional entries can be added by using a unique trigger starting with an upper case letter and followed by lower case
|
| 5 |
+
letters.
|
| 6 |
+
Each new entry should be a dictionary giving the molecular formula
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
abbrvs = {
|
| 10 |
+
# common chemical abbreviations
|
| 11 |
+
'Ac': {'C': 2, 'H': 3, 'O': 1}, # acetyl
|
| 12 |
+
'Ar+': {'C': 25, 'H': 21, 'P': 1}, # phosphonium tagged aryl group
|
| 13 |
+
'Bn': {'C': 7, 'H': 7}, # benzyl
|
| 14 |
+
'Bu': {'C': 4, 'H': 8}, # butyl
|
| 15 |
+
'Cod': {'C': 8, 'H': 12}, # cyclooctadiene
|
| 16 |
+
'Cp': {'C': 5, 'H': 5}, # cyclopenadienyl
|
| 17 |
+
'Cp*': {'C': 10, 'H': 15}, # pentamethylcyclopentadienyl
|
| 18 |
+
'Cy': {'C': 6, 'H': 11}, # cyclohexyl
|
| 19 |
+
'Et': {'C': 2, 'H': 4}, # ethyl
|
| 20 |
+
'Dba': {'C': 17, 'H': 14, 'O': 1}, # dibenzylideneacetone
|
| 21 |
+
'Dbu': {'C': 9, 'H': 16, 'N': 2}, # 1,8-diazabicycloundec-7-ene
|
| 22 |
+
'Dmf': {'C': 3, 'H': 7, 'N': 1, 'O': 1}, # dimethylformamide
|
| 23 |
+
'Dppm': {'C': 25, 'H': 22, 'P': 2}, # bis(diphenylphosphino)methane
|
| 24 |
+
'Dppe': {'C': 26, 'H': 24, 'P': 2}, # bis(diphenylphosphino)ethane
|
| 25 |
+
'Dppp': {'C': 27, 'H': 26, 'P': 2}, # bis(diphenylphosphino)propane
|
| 26 |
+
'L': {'C': 18, 'H': 15, 'P': 1}, # triphenylphosphine
|
| 27 |
+
'Im+': {'C': 11, 'H': 12, 'N': 2}, # imidazolium tagged aryl group
|
| 28 |
+
'Me': {'C': 1, 'H': 3}, # methyl
|
| 29 |
+
'Mes': {'C': 9, 'H': 12}, # mesitylene
|
| 30 |
+
'Ph': {'C': 6, 'H': 5}, # phenyl
|
| 31 |
+
'Pr': {'C': 3, 'H': 6}, # propyl
|
| 32 |
+
'Py': {'C': 5, 'H': 5, 'N': 1}, # pyridine
|
| 33 |
+
'Tf': {'C': 1, 'F': 3, 'O': 2, 'S': 1}, # trifluoromethanesulfonyl
|
| 34 |
+
'Thf': {'C': 4, 'H': 8, 'O': 1}, # tetrahydrofuran
|
| 35 |
+
'Tol': {'C': 7, 'H': 7}, # tolyl
|
| 36 |
+
'Tr': {'C': 19, 'H': 15}, # triphenylmethyl
|
| 37 |
+
|
| 38 |
+
# amino acids (assumes in polypeptide form; subtracting 1 O and 2 H from each)
|
| 39 |
+
'Ala': {'C': 3, 'H': 5, 'N': 1, 'O': 1}, # alanine
|
| 40 |
+
'Arg': {'C': 6, 'H': 11, 'N': 4, 'O': 1}, # arginine
|
| 41 |
+
'Asn': {'C': 4, 'H': 6, 'N': 2, 'O': 2}, # asparagine
|
| 42 |
+
'Asp': {'C': 4, 'H': 5, 'N': 1, 'O': 3}, # aspartic acid
|
| 43 |
+
'Cys': {'C': 3, 'H': 5, 'N': 1, 'O': 1, 'S': 1}, # cysteine
|
| 44 |
+
'Gln': {'C': 5, 'H': 8, 'N': 2, 'O': 1}, # glutamine
|
| 45 |
+
'Glu': {'C': 5, 'H': 7, 'N': 1, 'O': 3}, # glutamic acid
|
| 46 |
+
'Gly': {'C': 2, 'H': 3, 'N': 1, 'O': 1}, # glycine
|
| 47 |
+
'His': {'C': 6, 'H': 7, 'N': 3, 'O': 1}, # histidine
|
| 48 |
+
'Ile': {'C': 6, 'H': 11, 'N': 1, 'O': 1}, # isoleucine
|
| 49 |
+
'Leu': {'C': 6, 'H': 11, 'N': 1, 'O': 1}, # leucine
|
| 50 |
+
'Lys': {'C': 6, 'H': 12, 'N': 2, 'O': 1}, # lysine
|
| 51 |
+
'Met': {'C': 5, 'H': 9, 'N': 1, 'O': 1, 'S': 1}, # methionine
|
| 52 |
+
'Phe': {'C': 9, 'H': 9, 'N': 1, 'O': 1}, # phenylalanine
|
| 53 |
+
'Pro': {'C': 5, 'H': 7, 'N': 1, 'O': 1}, # proline
|
| 54 |
+
'Ser': {'C': 3, 'H': 5, 'N': 1, 'O': 2}, # serine
|
| 55 |
+
'Thr': {'C': 4, 'H': 7, 'N': 1, 'O': 2}, # threonine
|
| 56 |
+
'Trp': {'C': 11, 'H': 10, 'N': 2, 'O': 1}, # tryptophan
|
| 57 |
+
'Tyr': {'C': 9, 'H': 9, 'N': 1, 'O': 2}, # tryosine
|
| 58 |
+
'Val': {'C': 5, 'H': 9, 'N': 1, 'O': 1}, # valine
|
| 59 |
+
}
|
lib/pythoms/mass_dictionaries.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
lib/pythoms/molecule.py
ADDED
|
@@ -0,0 +1,2117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
IGNORE:
|
| 3 |
+
Molecule class (previously "isotope pattern generator" and "MolecularFormula")
|
| 4 |
+
|
| 5 |
+
The output of this builder has been validated against values calculated by ChemCalc (www.chemcalc.org)
|
| 6 |
+
Negligable differences are attributed to different low value discarding techniques
|
| 7 |
+
(ChemCalc keeps the top 5000 peaks, this script drops values less than a threshold 5 orders of magnitude below the
|
| 8 |
+
maximum value)
|
| 9 |
+
|
| 10 |
+
CHANGELOG:
|
| 11 |
+
- added exact mass comparison
|
| 12 |
+
- separated fwhm calculation from sigma
|
| 13 |
+
- fwhm calculation now uses monoisotopic mass
|
| 14 |
+
- barisotope pattern now groups using the full width at half max
|
| 15 |
+
- gaussian isotope pattern generation now works off of rawip by default
|
| 16 |
+
- updated to use Progress class
|
| 17 |
+
- updated gaussian isotope pattern generator to automatically determine the appropriate decimal places
|
| 18 |
+
---2.9 INCOMPATIBLE WITH SPECTRUM v2.4 or older
|
| 19 |
+
- moved charge application to raw isotope pattern function
|
| 20 |
+
- fixed bug in validation function for charged molecules
|
| 21 |
+
- added support for and enabled auto-saving of molecule instances (loading and saving to .mol files)
|
| 22 |
+
IGNORE
|
| 23 |
+
"""
|
| 24 |
+
import sys
|
| 25 |
+
import pickle
|
| 26 |
+
import os
|
| 27 |
+
import importlib.util
|
| 28 |
+
import numpy as np
|
| 29 |
+
from scipy import stats
|
| 30 |
+
from datetime import datetime
|
| 31 |
+
import sympy as sym
|
| 32 |
+
import pylab as pl
|
| 33 |
+
import copy
|
| 34 |
+
from .scripttime import ScriptTime
|
| 35 |
+
from .spectrum import Spectrum, weighted_average
|
| 36 |
+
from .progress import Progress
|
| 37 |
+
from . import mass_dictionaries # import mass dictionaries
|
| 38 |
+
from itertools import combinations_with_replacement as cwr
|
| 39 |
+
from IsoSpecPy import IsoThreshold
|
| 40 |
+
|
| 41 |
+
# flag for reminding folk to cite people
|
| 42 |
+
_CITATION_REMINDER = False
|
| 43 |
+
|
| 44 |
+
# attempt to load abbreviation dictionary from current working directory
|
| 45 |
+
from .mass_abbreviations import abbrvs
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
abbrv_spec = importlib.util.spec_from_file_location(
|
| 49 |
+
'user_abbrvs',
|
| 50 |
+
os.path.join(
|
| 51 |
+
os.getcwd(),
|
| 52 |
+
'user_mass_abbreviations.py'
|
| 53 |
+
)
|
| 54 |
+
)
|
| 55 |
+
abbrv_module = importlib.util.module_from_spec(abbrv_spec)
|
| 56 |
+
abbrv_spec.loader.exec_module(abbrv_module)
|
| 57 |
+
user_abbrvs = abbrv_module.user_abbrvs
|
| 58 |
+
abbrvs.update(user_abbrvs)
|
| 59 |
+
except FileNotFoundError: # if it can't find the file, continue with default abbreviations
|
| 60 |
+
pass
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
"""Mass dictionary associated with the instance"""
|
| 64 |
+
MASS_KEY = 'crc_mass'
|
| 65 |
+
mass_dict = getattr(
|
| 66 |
+
mass_dictionaries,
|
| 67 |
+
MASS_KEY,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
st = ScriptTime(profile=True)
|
| 72 |
+
|
| 73 |
+
# valid start and end brackets
|
| 74 |
+
OPENING_BRACKETS = ['(', '{', '['] # opening brackets
|
| 75 |
+
CLOSING_BRACKETS = [')', '}', ']'] # closing brackets
|
| 76 |
+
SIGNS = ['+', '-'] # charge signs
|
| 77 |
+
VERBOSE = False # toggle for verbose
|
| 78 |
+
|
| 79 |
+
# valid grouping methods
|
| 80 |
+
VALID_GROUP_METHODS = [
|
| 81 |
+
'weighted',
|
| 82 |
+
'centroid',
|
| 83 |
+
]
|
| 84 |
+
# valid isotope pattern generation methods
|
| 85 |
+
VALID_IPMETHODS = [
|
| 86 |
+
'combinatorics',
|
| 87 |
+
'multiplicative',
|
| 88 |
+
'hybrid',
|
| 89 |
+
'isospec', # use isospecpy package
|
| 90 |
+
# 'cuda',
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
# valid dropping methods
|
| 94 |
+
VALID_DROPMETHODS = [
|
| 95 |
+
None, # no dropping
|
| 96 |
+
'threshold', # drop values below threshold
|
| 97 |
+
'npeaks', # keep top n number of peaks
|
| 98 |
+
# 'consolidate', # consolidate intensities
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
# default threshold for low-intensity peak dropping
|
| 102 |
+
THRESHOLD = 0.01
|
| 103 |
+
# number of peaks to keep for low-intensity peak dropping
|
| 104 |
+
NPEAKS = 5000
|
| 105 |
+
# consolidation threshold for low-intensity peak combination
|
| 106 |
+
CONSOLIDATE = 3
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def interpret(block: str):
|
| 110 |
+
"""
|
| 111 |
+
Interprets an element block, breaking it into element and number of that element.
|
| 112 |
+
|
| 113 |
+
:param block: string block describing an element
|
| 114 |
+
:return: composition dictionary
|
| 115 |
+
:rtype: dict
|
| 116 |
+
"""
|
| 117 |
+
if block[0].isdigit() is True: # if isotope number is encountered
|
| 118 |
+
return {block: 1}
|
| 119 |
+
else:
|
| 120 |
+
ele = block[0]
|
| 121 |
+
i = 0
|
| 122 |
+
num = ''
|
| 123 |
+
while i < len(block) - 1:
|
| 124 |
+
i += 1
|
| 125 |
+
if block[i].isdigit() is True: # add digits
|
| 126 |
+
num += block[i]
|
| 127 |
+
elif block[i] == ' ': # ignore spaces
|
| 128 |
+
continue
|
| 129 |
+
else:
|
| 130 |
+
ele += block[i]
|
| 131 |
+
if num == '':
|
| 132 |
+
num = 1
|
| 133 |
+
else:
|
| 134 |
+
num = int(num)
|
| 135 |
+
return {ele: num}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def interpret_charge(string: str):
|
| 139 |
+
"""
|
| 140 |
+
Interprets a charge string.
|
| 141 |
+
|
| 142 |
+
:param string: string describing the charge (e.g. '2+')
|
| 143 |
+
:return: charge, sign
|
| 144 |
+
:rtype: tuple
|
| 145 |
+
"""
|
| 146 |
+
value = ''
|
| 147 |
+
sign = '+' # default value for sign
|
| 148 |
+
if type(string) is int:
|
| 149 |
+
return string, sign
|
| 150 |
+
for ind, val in enumerate(string):
|
| 151 |
+
if val in SIGNS: # if val sets mode
|
| 152 |
+
sign = val
|
| 153 |
+
else: # number
|
| 154 |
+
value += val
|
| 155 |
+
if value == '': # if no number was specified (e.g. "+")
|
| 156 |
+
value = 1
|
| 157 |
+
return int(value), sign
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def string_to_isotope(string: str):
|
| 161 |
+
"""
|
| 162 |
+
Attempts to interpret an undefined key as an isotope/element combination (e.g. "13C" becomes 'C', 13). Raises a
|
| 163 |
+
ValueError if the string cannot be interpreted as such.
|
| 164 |
+
|
| 165 |
+
:param string: string to interpret
|
| 166 |
+
:return: element, isotope
|
| 167 |
+
:rtype: (str, int)
|
| 168 |
+
"""
|
| 169 |
+
iso = string[0]
|
| 170 |
+
if iso.isdigit() is False:
|
| 171 |
+
raise TypeError(f'The isotope "{string}" is not a valid format. Use isotope/element format e.g. "12C"')
|
| 172 |
+
ele = ''
|
| 173 |
+
i = 1
|
| 174 |
+
try:
|
| 175 |
+
while i < len(string):
|
| 176 |
+
if string[i].isdigit() is True:
|
| 177 |
+
iso += string[i]
|
| 178 |
+
i += 1
|
| 179 |
+
if string[i].isalpha() is True:
|
| 180 |
+
ele += string[i]
|
| 181 |
+
i += 1
|
| 182 |
+
return ele, int(iso)
|
| 183 |
+
except ValueError:
|
| 184 |
+
raise ValueError(
|
| 185 |
+
f'The string "{string}" could not be interpreted as an element, isotope combination, please check'
|
| 186 |
+
f'your input')
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
unicode_subscripts = { # subscripts values for unit representations
|
| 190 |
+
0: f'\u2080',
|
| 191 |
+
1: f'\u2081',
|
| 192 |
+
2: f'\u2082',
|
| 193 |
+
3: f'\u2083',
|
| 194 |
+
4: f'\u2084',
|
| 195 |
+
5: f'\u2085',
|
| 196 |
+
6: f'\u2086',
|
| 197 |
+
7: f'\u2087',
|
| 198 |
+
8: f'\u2088',
|
| 199 |
+
9: f'\u2089',
|
| 200 |
+
}
|
| 201 |
+
unicode_superscripts = { # superscript values for unit representations
|
| 202 |
+
0: f'\u2070',
|
| 203 |
+
1: f'\u00b9',
|
| 204 |
+
2: f'\u00b2',
|
| 205 |
+
3: f'\u00b3',
|
| 206 |
+
4: f'\u2074',
|
| 207 |
+
5: f'\u2075',
|
| 208 |
+
6: f'\u2076',
|
| 209 |
+
7: f'\u2077',
|
| 210 |
+
8: f'\u2078',
|
| 211 |
+
9: f'\u2079',
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def to_subscript(number):
|
| 216 |
+
"""
|
| 217 |
+
Converts the value to subscript characters.
|
| 218 |
+
|
| 219 |
+
:param int number: number to convert
|
| 220 |
+
:return: subscript
|
| 221 |
+
:rtype: str
|
| 222 |
+
"""
|
| 223 |
+
return ''.join(
|
| 224 |
+
[unicode_subscripts[int(val)] for val in str(abs(number))]
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def to_superscript(val):
|
| 229 |
+
"""
|
| 230 |
+
Returns the integer value represented as a superscript string.
|
| 231 |
+
|
| 232 |
+
:param int val: value to represent
|
| 233 |
+
:return: superscript string
|
| 234 |
+
:rtype: str
|
| 235 |
+
"""
|
| 236 |
+
return ''.join(
|
| 237 |
+
[unicode_superscripts[int(val)] for val in str(abs(val))]
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def check_in_mass_dict(comp: dict):
|
| 242 |
+
"""
|
| 243 |
+
Checks for the presence of the dictionary keys in the mass dictionary. Raises a ValueError if the key is not found.
|
| 244 |
+
|
| 245 |
+
:param comp: composition dictionary
|
| 246 |
+
"""
|
| 247 |
+
for key in comp:
|
| 248 |
+
if key not in mass_dict:
|
| 249 |
+
ele, iso = string_to_isotope(key)
|
| 250 |
+
if ele not in mass_dict:
|
| 251 |
+
raise ValueError(f'The element {ele} is not defined in the mass dictionary. Please check your input.')
|
| 252 |
+
elif iso not in mass_dict[ele]:
|
| 253 |
+
raise ValueError(
|
| 254 |
+
f'The element "{ele}" does not have a defined isotope "{iso}" in the mass dictionary. '
|
| 255 |
+
f'Please check your input.'
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def element_intensity_list(element: str):
|
| 260 |
+
"""
|
| 261 |
+
Returns the non-zero element intensity for the specified element.
|
| 262 |
+
|
| 263 |
+
:param element: element key
|
| 264 |
+
:return: mass, intensity lists
|
| 265 |
+
:rtype: list
|
| 266 |
+
"""
|
| 267 |
+
if element not in mass_dict:
|
| 268 |
+
raise KeyError(f'The element {element} is not defined in the mass dictionary.')
|
| 269 |
+
ele_dict = mass_dict[element]
|
| 270 |
+
mass_out = []
|
| 271 |
+
intensity_out = []
|
| 272 |
+
for isotope in ele_dict:
|
| 273 |
+
if isotope != 0 and ele_dict[isotope][1] != 0.:
|
| 274 |
+
mass_out.append(ele_dict[isotope][0])
|
| 275 |
+
intensity_out.append(ele_dict[isotope][1])
|
| 276 |
+
return [mass_out, intensity_out]
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def chew_formula(formula: str):
|
| 280 |
+
"""
|
| 281 |
+
Iterates through provided formula, extracting blocks, interpreting the blocks,
|
| 282 |
+
and returning the formula minus the blocks.
|
| 283 |
+
|
| 284 |
+
:param formula: string formula
|
| 285 |
+
:return: remaining formula, interpreted block
|
| 286 |
+
:rtype: str, dict
|
| 287 |
+
"""
|
| 288 |
+
if formula[0].isupper() is True: # element is recognized by an uppercase letter
|
| 289 |
+
block = formula[0] # element block
|
| 290 |
+
for loc in range(len(formula)):
|
| 291 |
+
if loc == 0:
|
| 292 |
+
continue
|
| 293 |
+
if formula[loc].isupper() is True: # if an uppercase character is encountered
|
| 294 |
+
break
|
| 295 |
+
elif formula[loc] in OPENING_BRACKETS: # if a bracket is encountered
|
| 296 |
+
break
|
| 297 |
+
else:
|
| 298 |
+
block += formula[loc]
|
| 299 |
+
return formula[len(block):], interpret(block) # return remaining formula and the interpreted block
|
| 300 |
+
elif formula[0] in OPENING_BRACKETS: # if a bracket is encountered, intialize bracket interpretation
|
| 301 |
+
return bracket(formula)
|
| 302 |
+
elif formula[0].isdigit() is True: # either isotope or charge
|
| 303 |
+
if any([sign in formula for sign in SIGNS]): # if the block is a value-sign charge specification
|
| 304 |
+
return '', {'charge': formula}
|
| 305 |
+
for ind, val in enumerate(formula):
|
| 306 |
+
if formula[ind].isalpha() is True: # if isotope encountered, return that isotope with n=1
|
| 307 |
+
return '', {formula: 1}
|
| 308 |
+
elif formula[0] in SIGNS: # charge specification
|
| 309 |
+
return '', {'charge': formula} # assign as charge for later interpretation
|
| 310 |
+
else:
|
| 311 |
+
raise ValueError(f'An uninterpretable formula chunck was encountered: {formula}')
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def bracket(form):
|
| 315 |
+
"""finds the string block contained within a bracket and determines the formula within that bracket"""
|
| 316 |
+
bracktype = OPENING_BRACKETS.index(form[0]) # sets bracket type (so close bracket can be identified)
|
| 317 |
+
bnum = '' # number of things indicated in the bracket
|
| 318 |
+
block = '' # element block
|
| 319 |
+
nest = 1 # counter for nesting brackets
|
| 320 |
+
for loc in range(len(form)): # look for close bracket
|
| 321 |
+
if loc == 0:
|
| 322 |
+
continue
|
| 323 |
+
elif form[loc] == OPENING_BRACKETS[bracktype]: # if a nested bracket is encountered
|
| 324 |
+
nest += 1
|
| 325 |
+
block += form[loc]
|
| 326 |
+
elif form[loc] == CLOSING_BRACKETS[bracktype]: # if close bracket is encountered
|
| 327 |
+
nest -= 1
|
| 328 |
+
if nest == 0:
|
| 329 |
+
i = loc + 1 # index of close bracket
|
| 330 |
+
break
|
| 331 |
+
else:
|
| 332 |
+
block += form[loc]
|
| 333 |
+
else:
|
| 334 |
+
block += form[loc]
|
| 335 |
+
|
| 336 |
+
try: # look for digits outside of the bracket
|
| 337 |
+
while form[i].isdigit() is True:
|
| 338 |
+
bnum += form[i]
|
| 339 |
+
i += 1
|
| 340 |
+
except IndexError: # if i extends past the length of the formula
|
| 341 |
+
pass
|
| 342 |
+
except UnboundLocalError: # if a close bracket was not found, i will not be defined
|
| 343 |
+
raise ValueError(
|
| 344 |
+
f'A close bracket was not encountered for the "{form[0]}" bracket in the formula segment "{form}". '
|
| 345 |
+
f'Please check your input molecular formula.')
|
| 346 |
+
|
| 347 |
+
lblock = len(block) + len(
|
| 348 |
+
bnum) + 2 # length of the internal block + the length of the number + 2 for the brackets
|
| 349 |
+
if bnum == '': # if no number is specified
|
| 350 |
+
bnum = 1
|
| 351 |
+
else:
|
| 352 |
+
bnum = int(bnum)
|
| 353 |
+
outdict = {}
|
| 354 |
+
while len(block) > 0: # chew through bracket
|
| 355 |
+
ftemp, tempdict = chew_formula(block)
|
| 356 |
+
for key in tempdict:
|
| 357 |
+
try:
|
| 358 |
+
outdict[key] += tempdict[key] * bnum
|
| 359 |
+
except KeyError:
|
| 360 |
+
outdict[key] = tempdict[key] * bnum
|
| 361 |
+
block = ftemp
|
| 362 |
+
return form[lblock:], outdict # returns remaining formula and composition of the block
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def abbreviations(dct: dict):
|
| 366 |
+
"""
|
| 367 |
+
Searches for abbreviations predefined in mass_abbreviations.py either in the pythoms package or in the current
|
| 368 |
+
working directory. Any found abbreviations will be added to the current dictionary.
|
| 369 |
+
|
| 370 |
+
:param dct: incoming dictionary
|
| 371 |
+
:return: un-abbreviated dictionary
|
| 372 |
+
:rtype: dict
|
| 373 |
+
"""
|
| 374 |
+
comptemp = {}
|
| 375 |
+
for key in dct:
|
| 376 |
+
if key in abbrvs: # if a common abbreviation is found in formula
|
| 377 |
+
for subkey in abbrvs[key]:
|
| 378 |
+
try:
|
| 379 |
+
comptemp[subkey] += abbrvs[key][subkey] * dct[key]
|
| 380 |
+
except KeyError:
|
| 381 |
+
comptemp[subkey] = abbrvs[key][subkey] * dct[key]
|
| 382 |
+
else:
|
| 383 |
+
try:
|
| 384 |
+
comptemp[key] += dct[key]
|
| 385 |
+
except KeyError:
|
| 386 |
+
comptemp[key] = dct[key]
|
| 387 |
+
return comptemp
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def composition_from_formula(formula):
|
| 391 |
+
"""
|
| 392 |
+
Interprets a provided string as a molecular formula.
|
| 393 |
+
Supports nested brackets, charges, and isotopes.
|
| 394 |
+
|
| 395 |
+
:param formula: A molecular formula. Charge may be specified in the formula, but care must be taken to specify
|
| 396 |
+
the charge in sign-value format (e.g. '+2' if value-sign is specified, the script will attempt to interpret the
|
| 397 |
+
key as an isotope).
|
| 398 |
+
:return: A dictionary where each key is an element or isotope with its value
|
| 399 |
+
being the number of each of the elements or isotopes. e.g. the
|
| 400 |
+
molecule CH4 would have the composition ``comp = {'C':1, 'H':4}``
|
| 401 |
+
:rtype: dict
|
| 402 |
+
"""
|
| 403 |
+
comp = {}
|
| 404 |
+
while len(formula) > 0: # chew through formula
|
| 405 |
+
ftemp, nomdict = chew_formula(formula) # find the next block
|
| 406 |
+
for ele in nomdict:
|
| 407 |
+
try:
|
| 408 |
+
comp[ele] += nomdict[ele]
|
| 409 |
+
except KeyError:
|
| 410 |
+
comp[ele] = nomdict[ele]
|
| 411 |
+
formula = ftemp
|
| 412 |
+
comp = abbreviations(comp) # look for common abbreviations
|
| 413 |
+
return comp
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def standard_deviation(fwhm):
|
| 417 |
+
"""determines the standard deviation for a normal distribution with the full width at half max specified"""
|
| 418 |
+
return fwhm / (2 * np.sqrt(2 * np.log(2))) # based on the equation FWHM = 2*sqrt(2ln2)*sigma
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def group_masses(ip, dm: float = 0.25):
|
| 422 |
+
"""
|
| 423 |
+
Groups masses in an isotope pattern looking for differences in m/z greater than the specified delta.
|
| 424 |
+
expects
|
| 425 |
+
|
| 426 |
+
:param ip: a paired list of [[mz values],[intensity values]]
|
| 427 |
+
:param dm: Delta for looking +/- within
|
| 428 |
+
:return: blocks grouped by central mass
|
| 429 |
+
:rtype: list
|
| 430 |
+
"""
|
| 431 |
+
num = 0
|
| 432 |
+
out = [[[], []]]
|
| 433 |
+
for ind, val in enumerate(ip[0]):
|
| 434 |
+
out[num][0].append(ip[0][ind])
|
| 435 |
+
out[num][1].append(ip[1][ind])
|
| 436 |
+
try:
|
| 437 |
+
if ip[0][ind + 1] - ip[0][ind] > dm:
|
| 438 |
+
num += 1
|
| 439 |
+
out.append([[], []])
|
| 440 |
+
except IndexError:
|
| 441 |
+
continue
|
| 442 |
+
return out
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def centroid(ipgroup):
|
| 446 |
+
"""
|
| 447 |
+
takes a group of mz and intensity values and finds the centroid
|
| 448 |
+
this method results in substantially more error compared to the weighted_average method (~9 orders of
|
| 449 |
+
magnitude for C61H51IP3Pd)
|
| 450 |
+
"""
|
| 451 |
+
return sum(ipgroup[0]) / len(ipgroup[0]), sum(ipgroup[1]) / len(ipgroup[1])
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def bar_isotope_pattern(
|
| 455 |
+
rawip: list,
|
| 456 |
+
delta: float = 0.5,
|
| 457 |
+
method: str = 'weighted',
|
| 458 |
+
verbose: bool = VERBOSE,
|
| 459 |
+
):
|
| 460 |
+
"""
|
| 461 |
+
Converts a raw isotope pattern into a bar isotope pattern. This groups mass defects
|
| 462 |
+
that are within a given difference from each other into a single *m/z* value and
|
| 463 |
+
intensity.
|
| 464 |
+
|
| 465 |
+
:param rawip: The raw isotope pattern with no grouping applied
|
| 466 |
+
:param delta: The *m/z* difference to check around a peak when grouping it into a single *m/z* value.
|
| 467 |
+
The script will look delta/2 from the peak being checked
|
| 468 |
+
:param method: Method of combining (weighted or centroid). Weighted is recommended for accuracy
|
| 469 |
+
:param verbose: chatty mode
|
| 470 |
+
:return: bar isotope pattern in ``[[m/z values],[intensity values]]`` format.
|
| 471 |
+
:rtype: list
|
| 472 |
+
"""
|
| 473 |
+
if method not in VALID_GROUP_METHODS:
|
| 474 |
+
raise ValueError(f'The grouping method {method} is invalid. Choose from {", ".join(VALID_GROUP_METHODS)}')
|
| 475 |
+
if verbose is True:
|
| 476 |
+
sys.stdout.write('Generating bar isotope pattern')
|
| 477 |
+
if isinstance(rawip, Spectrum): # if handed a Spectrum object, trim before continuing
|
| 478 |
+
rawip = rawip.trim()
|
| 479 |
+
groupedip = group_masses(rawip, delta / 2)
|
| 480 |
+
out = [[], []]
|
| 481 |
+
for group in groupedip:
|
| 482 |
+
if method == 'weighted':
|
| 483 |
+
x, y = weighted_average(*group) # determine weighted mass and summed intensity
|
| 484 |
+
elif method == 'centroid':
|
| 485 |
+
x, y = centroid(group)
|
| 486 |
+
out[0].append(x)
|
| 487 |
+
out[1].append(y)
|
| 488 |
+
maxint = max(out[1])
|
| 489 |
+
for ind, val in enumerate(out[1]):
|
| 490 |
+
out[0][ind] = out[0][ind] # / abs(charge)
|
| 491 |
+
out[1][ind] = val / maxint * 100. # normalize to 100
|
| 492 |
+
if verbose is True:
|
| 493 |
+
sys.stdout.write(' DONE\n')
|
| 494 |
+
return out
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def normal_distribution(center, fwhm, height):
|
| 498 |
+
"""
|
| 499 |
+
Generates a normal distribution about the center with the full width at half max specified. Y-values will be
|
| 500 |
+
normalized to the height specified.
|
| 501 |
+
|
| 502 |
+
:param center: center x value for the distribution
|
| 503 |
+
:param fwhm: full-width-at-half-maximum
|
| 504 |
+
:param height: maximum value for the y list
|
| 505 |
+
:return: x values, y values
|
| 506 |
+
:rtype: list
|
| 507 |
+
"""
|
| 508 |
+
x = np.arange(
|
| 509 |
+
center - fwhm * 2,
|
| 510 |
+
center + fwhm * 2,
|
| 511 |
+
10 ** -autodec(fwhm),
|
| 512 |
+
dtype=np.float64,
|
| 513 |
+
)
|
| 514 |
+
y = stats.norm.pdf( # generate normal distribution
|
| 515 |
+
x,
|
| 516 |
+
float(center), # type-convert from sympy Float
|
| 517 |
+
standard_deviation(fwhm),
|
| 518 |
+
)
|
| 519 |
+
y /= max(y) # normalize
|
| 520 |
+
y = y * height
|
| 521 |
+
return [x.tolist(), y.tolist()]
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def autodec(fwhm):
|
| 525 |
+
"""
|
| 526 |
+
Automatically calculates the appropriate decimal place to track based on a full-width-at-half-maximum
|
| 527 |
+
|
| 528 |
+
:param fwhm: full-width-at-half-maximum
|
| 529 |
+
:return: decimal power
|
| 530 |
+
:rtype: int
|
| 531 |
+
"""
|
| 532 |
+
shift = fwhm
|
| 533 |
+
n = 0
|
| 534 |
+
while shift < 1.:
|
| 535 |
+
n += 1
|
| 536 |
+
shift = fwhm * 10 ** n
|
| 537 |
+
return n + 1 # track 1 higher
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def gaussian_isotope_pattern(
|
| 541 |
+
barip: list,
|
| 542 |
+
fwhm: float,
|
| 543 |
+
verbose: bool = VERBOSE,
|
| 544 |
+
):
|
| 545 |
+
"""
|
| 546 |
+
Simulates the isotope pattern that would be observed in a mass
|
| 547 |
+
spectrometer with the resolution specified as a fwhm value.
|
| 548 |
+
|
| 549 |
+
:param barip: The isotope pattern to be simulated. This can be either the bar isotope
|
| 550 |
+
pattern or the raw isotope pattern (although this will be substantially
|
| 551 |
+
slower for large molecules).
|
| 552 |
+
:param fwhm: full-width-at-half-maximum
|
| 553 |
+
:param verbose: chatty mode
|
| 554 |
+
:return: The predicted gaussian isotope pattern in the form of a paired list
|
| 555 |
+
``[[m/z values],[intensity values]]``
|
| 556 |
+
:rtype: list
|
| 557 |
+
"""
|
| 558 |
+
spec = Spectrum( # generate Spectrum object to encompass the entire region
|
| 559 |
+
autodec(fwhm),
|
| 560 |
+
start=min(barip[0]) - fwhm * 2,
|
| 561 |
+
end=max(barip[0]) + fwhm * 2,
|
| 562 |
+
empty=False, # whether or not to use emptyspec
|
| 563 |
+
filler=0., # fill with zeros, not None
|
| 564 |
+
)
|
| 565 |
+
for ind, val in enumerate(barip[0]): # generate normal distributions for each peak
|
| 566 |
+
# if verbose is True:
|
| 567 |
+
# sys.stdout.write('\rSumming m/z %.3f %d/%d' %(val,ind+1,len(self.barip[0])))
|
| 568 |
+
nd = normal_distribution(val, fwhm, barip[1][ind]) # generate normal distribution for that peak
|
| 569 |
+
spec.add_spectrum(nd[0], nd[1]) # add the generated spectrum to the total spectrum
|
| 570 |
+
spec.normalize() # normalize
|
| 571 |
+
gausip = spec.trim() # trim None values and output
|
| 572 |
+
if verbose is True:
|
| 573 |
+
sys.stdout.write(' DONE\n')
|
| 574 |
+
return gausip
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def isotope_pattern_hybrid(
|
| 578 |
+
composition: dict,
|
| 579 |
+
fwhm: float,
|
| 580 |
+
decpl: int,
|
| 581 |
+
verbose: bool = VERBOSE,
|
| 582 |
+
dropmethod: str = None,
|
| 583 |
+
threshold: float = THRESHOLD,
|
| 584 |
+
npeaks: int = NPEAKS,
|
| 585 |
+
consolidate: float = CONSOLIDATE,
|
| 586 |
+
**kwargs,
|
| 587 |
+
):
|
| 588 |
+
"""
|
| 589 |
+
A hybrid isotope pattern calculator which calculates the isotope pattern from each element, then multiplies the
|
| 590 |
+
lists.
|
| 591 |
+
|
| 592 |
+
:param composition: composition dictionary
|
| 593 |
+
:param fwhm: full-width-at-half-maximum
|
| 594 |
+
:param decpl: decimal places to track in the Spectrum object
|
| 595 |
+
:param verbose: chatty mode
|
| 596 |
+
:param dropmethod: optional method to use for low-intensity peak dropping or consolidation. Valid options are
|
| 597 |
+
'threshold', 'npeaks', or 'consolidate'.
|
| 598 |
+
:param threshold: if the dropmethod is set to 'threshold', any peaks below this threshold will be dropped.
|
| 599 |
+
:param npeaks: if the dropmethod is set to 'npeaks', the top n peaks will be kept, with the rest being dropped.
|
| 600 |
+
:param consolidate: if the dropmethod is set to 'consolidate', any peaks below the threshold will be consolidated
|
| 601 |
+
into adjacent peaks using a weighted average. Any peaks that do not have a neighbour within 10^-`consolidate`
|
| 602 |
+
will be dropped entirely.
|
| 603 |
+
:return: isotope pattern as a Spectrum object
|
| 604 |
+
:rtype: Spectrum
|
| 605 |
+
"""
|
| 606 |
+
eleips = {} # dictionary for storing the isotope patterns of each element
|
| 607 |
+
for element, number in composition.items():
|
| 608 |
+
eleips[element] = isotope_pattern_combinatoric( # calculate the isotope pattern for each element
|
| 609 |
+
{element: number},
|
| 610 |
+
decpl=decpl,
|
| 611 |
+
verbose=verbose,
|
| 612 |
+
).trim() # trim the generated spectra to lists
|
| 613 |
+
|
| 614 |
+
sortlist = []
|
| 615 |
+
for element in eleips:
|
| 616 |
+
sortlist.append((
|
| 617 |
+
len(eleips[element][0]),
|
| 618 |
+
element
|
| 619 |
+
))
|
| 620 |
+
sortlist = sorted(sortlist) # sorted list of elements based on the length of their isotope patterns
|
| 621 |
+
sortlist.reverse()
|
| 622 |
+
if verbose is True:
|
| 623 |
+
prog = Progress(
|
| 624 |
+
last=len(sortlist) - 1,
|
| 625 |
+
percent=False,
|
| 626 |
+
fraction=False,
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
spec = None
|
| 630 |
+
for lenlist, element in sortlist:
|
| 631 |
+
if verbose is True:
|
| 632 |
+
prog.string = f'Adding element {element} to isotope pattern'
|
| 633 |
+
prog.write(1)
|
| 634 |
+
if spec is None:
|
| 635 |
+
spec = Spectrum(
|
| 636 |
+
autodec(fwhm), # decimal places
|
| 637 |
+
start=None, # minimum mass
|
| 638 |
+
end=None, # maximum mass
|
| 639 |
+
empty=True, # whether or not to use emptyspec
|
| 640 |
+
filler=0., # fill with zeros, not None
|
| 641 |
+
specin=eleips[element], # supply masses and abundances as initialization spectrum
|
| 642 |
+
)
|
| 643 |
+
if verbose is True:
|
| 644 |
+
prog.fin()
|
| 645 |
+
|
| 646 |
+
continue
|
| 647 |
+
spec.add_element(eleips[element][0], eleips[element][1])
|
| 648 |
+
spec.normalize(100.) # normalize spectrum object
|
| 649 |
+
if dropmethod == 'threshold': # drop values below threshold
|
| 650 |
+
spec.threshold(threshold)
|
| 651 |
+
elif dropmethod == 'npeaks': # keep top n number of peaks
|
| 652 |
+
spec.keep_top_n(npeaks)
|
| 653 |
+
elif dropmethod == 'consolidate': # consolidate values being dropped
|
| 654 |
+
spec.consolidate(
|
| 655 |
+
threshold,
|
| 656 |
+
3 * 10 ** -consolidate
|
| 657 |
+
)
|
| 658 |
+
if verbose is True:
|
| 659 |
+
sys.stdout.write(' DONE\n')
|
| 660 |
+
return spec
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
class ReiterableCWR(object):
|
| 664 |
+
def __init__(self, isos, number):
|
| 665 |
+
"""a reiterable version of combinations with replacements iterator"""
|
| 666 |
+
self.isos = isos # isotopes group
|
| 667 |
+
self.number = number # number of atoms of the element
|
| 668 |
+
|
| 669 |
+
def __iter__(self):
|
| 670 |
+
return cwr(self.isos, self.number)
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
@st.profilefn
|
| 674 |
+
def num_permu(lst, isos):
|
| 675 |
+
"""
|
| 676 |
+
Calculates the number of unique permutations of the given set of isotopes for an element.
|
| 677 |
+
The calculation is generated as a sympy function before evaluation. numpy factorial is limited in the size of
|
| 678 |
+
factorials that are calculable, so sympy is required.
|
| 679 |
+
|
| 680 |
+
:param lst: list of isotopes in the combination
|
| 681 |
+
:param isos: possible isotopes for that element
|
| 682 |
+
:return: number of occurrences of this list of isotopes
|
| 683 |
+
:rtype: int
|
| 684 |
+
"""
|
| 685 |
+
counts = [lst.count(x) for x in isos] # counts the number of each isotope in the set
|
| 686 |
+
num = sym.factorial(len(lst)) # numerator is the factorial of the length of the list
|
| 687 |
+
denom = 1 # denominator is the product of the factorials of the counts of each isotope in the list
|
| 688 |
+
for count in counts:
|
| 689 |
+
denom *= sym.factorial(count)
|
| 690 |
+
return int((num / denom).evalf()) # divide, evaluate, and return integer
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
@st.profilefn
|
| 694 |
+
def product(*iterables):
|
| 695 |
+
"""
|
| 696 |
+
cartesian product of iterables
|
| 697 |
+
from http://stackoverflow.com/questions/12093364/cartesian-product-of-large-iterators-itertools
|
| 698 |
+
"""
|
| 699 |
+
if len(iterables) == 0:
|
| 700 |
+
yield ()
|
| 701 |
+
else:
|
| 702 |
+
it = iterables[0]
|
| 703 |
+
for item in it() if callable(it) else iter(it):
|
| 704 |
+
for items in product(*iterables[1:]):
|
| 705 |
+
yield (item,) + items
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
@st.profilefn
|
| 709 |
+
def numberofcwr(n, k):
|
| 710 |
+
"""
|
| 711 |
+
calculates the number of combinations with repitition
|
| 712 |
+
n: number of things to choose from
|
| 713 |
+
k: choose k of them
|
| 714 |
+
"""
|
| 715 |
+
fn = sym.factorial(n + k - 1)
|
| 716 |
+
fn /= sym.factorial(k)
|
| 717 |
+
fn /= sym.factorial(n - 1)
|
| 718 |
+
return fn.evalf()
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
def cpu_list_product(iterable):
|
| 722 |
+
"""returns the product of a list"""
|
| 723 |
+
prod = 1
|
| 724 |
+
for n in iterable:
|
| 725 |
+
prod *= n
|
| 726 |
+
return prod
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
@st.profilefn
|
| 730 |
+
def isotope_pattern_combinatoric(
|
| 731 |
+
comp: dict,
|
| 732 |
+
decpl: int,
|
| 733 |
+
verbose: bool = VERBOSE,
|
| 734 |
+
**kwargs, # catch for extra keyword arguments
|
| 735 |
+
):
|
| 736 |
+
"""
|
| 737 |
+
Calculates the raw isotope pattern of a given molecular formula with mass defects preserved.
|
| 738 |
+
Uses a combinatorial method to generate isotope formulae
|
| 739 |
+
|
| 740 |
+
:param comp: composition dictionary
|
| 741 |
+
:param decpl: decimal places to track in the Spectrum object
|
| 742 |
+
:param verbose: chatty mode
|
| 743 |
+
:return: raw isotope pattern as a Spectrum object
|
| 744 |
+
:rtype: Spectrum
|
| 745 |
+
"""
|
| 746 |
+
speciso = False # set state for specific isotope
|
| 747 |
+
isos = {} # isotopes dictionary
|
| 748 |
+
isosets = {} # set of isotopes for each element
|
| 749 |
+
iterators = [] # list of iterators
|
| 750 |
+
nk = []
|
| 751 |
+
for element in comp: # for each element
|
| 752 |
+
if element in mass_dict:
|
| 753 |
+
isosets[element] = [] # set of isotopes
|
| 754 |
+
for isotope in mass_dict[element]: # for each isotope of that element in the mass dictionary
|
| 755 |
+
if isotope != 0 and mass_dict[element][isotope][1] != 0: # of the intensity is nonzero
|
| 756 |
+
isosets[element].append(isotope) # track set of isotopes
|
| 757 |
+
isos[isotope] = element # create isotope,element association for reference
|
| 758 |
+
iterators.append(
|
| 759 |
+
ReiterableCWR( # create iterator instance
|
| 760 |
+
isosets[element],
|
| 761 |
+
comp[element]
|
| 762 |
+
)
|
| 763 |
+
)
|
| 764 |
+
if verbose is True:
|
| 765 |
+
nk.append([ # track n and k for list length output
|
| 766 |
+
len(isosets[element]),
|
| 767 |
+
comp[element]
|
| 768 |
+
])
|
| 769 |
+
else: # if it's an isotope
|
| 770 |
+
speciso = True
|
| 771 |
+
|
| 772 |
+
spec = Spectrum( # initiate spectrum object
|
| 773 |
+
decpl, # decimal places
|
| 774 |
+
start=None, # no minimum mass
|
| 775 |
+
end=None, # no maximum mass
|
| 776 |
+
empty=True, # whether or not to use emptyspec
|
| 777 |
+
filler=0., # fill with zeros, not None
|
| 778 |
+
)
|
| 779 |
+
if verbose is True:
|
| 780 |
+
counter = 0 # create a counter
|
| 781 |
+
iterations = int(cpu_list_product([numberofcwr(n, k) for n, k in nk])) # number of iterations
|
| 782 |
+
prog = Progress( # create a progress instance
|
| 783 |
+
string='Processing isotope combination',
|
| 784 |
+
last=iterations
|
| 785 |
+
)
|
| 786 |
+
|
| 787 |
+
for comb in product(*iterators):
|
| 788 |
+
if verbose is True:
|
| 789 |
+
counter += 1
|
| 790 |
+
# remaining = st.progress(counter,iterations,'combinations')
|
| 791 |
+
prog.write(counter)
|
| 792 |
+
num = 1 # number of combinations counter
|
| 793 |
+
x = 0. # mass value
|
| 794 |
+
y = 1. # intensity value
|
| 795 |
+
for tup in comb: # for each element combination
|
| 796 |
+
element = isos[tup[0]] # associate isotope to element
|
| 797 |
+
# counts = [tup.count(x) for x in isosets[element]] # count the number of occurances of each isotope
|
| 798 |
+
# num *= num_permu(tup,counts) # determine the number of permutations of the set
|
| 799 |
+
# for ind,isotope in enumerate(isosets[element]):
|
| 800 |
+
# x += self.md[element][isotope][0] * counts[ind]
|
| 801 |
+
# y *= self.md[element][isotope][1] ** counts[ind]
|
| 802 |
+
num *= num_permu(tup, isosets[element]) # multiply the number by the possible permutations
|
| 803 |
+
for isotope in tup: # for each isotope
|
| 804 |
+
x += mass_dict[element][isotope][0] # shift x
|
| 805 |
+
y *= mass_dict[element][isotope][1] # multiply intensity
|
| 806 |
+
# add the x and y combination factored by the number of times that combination will occur
|
| 807 |
+
spec.add_value(x, y * num)
|
| 808 |
+
|
| 809 |
+
if speciso is True: # if an isotope was specified
|
| 810 |
+
for element in comp:
|
| 811 |
+
if element not in mass_dict: # if an isotope
|
| 812 |
+
ele, iso = string_to_isotope(element) # determine element and isotope
|
| 813 |
+
spec.shift_x(mass_dict[ele][iso][0] * comp[element]) # shift the x values by the isotopic mass
|
| 814 |
+
spec.normalize() # normalize the spectrum object
|
| 815 |
+
if verbose is True:
|
| 816 |
+
prog.fin()
|
| 817 |
+
return spec
|
| 818 |
+
|
| 819 |
+
|
| 820 |
+
@st.profilefn
|
| 821 |
+
def isotope_pattern_multiplicative(
|
| 822 |
+
comp: dict,
|
| 823 |
+
decpl: int,
|
| 824 |
+
verbose: bool = VERBOSE,
|
| 825 |
+
dropmethod: str = None,
|
| 826 |
+
threshold: float = THRESHOLD,
|
| 827 |
+
npeaks: int = NPEAKS,
|
| 828 |
+
consolidate: float = CONSOLIDATE,
|
| 829 |
+
**kwargs,
|
| 830 |
+
):
|
| 831 |
+
"""
|
| 832 |
+
Calculates the raw isotope pattern of a given molecular formula with mass defects preserved.
|
| 833 |
+
|
| 834 |
+
:param comp: The molecular composition dictionary. See ``Molecule.composition`` for more details.
|
| 835 |
+
:param decpl: The number of decimal places to track. This is normally controlled by the keyword
|
| 836 |
+
arguments of the class, but can be specified if called separately.
|
| 837 |
+
:param verbose: chatty mode
|
| 838 |
+
:param dropmethod: optional method to use for low-intensity peak dropping or consolidation. Valid options are
|
| 839 |
+
'threshold', 'npeaks', or 'consolidate'.
|
| 840 |
+
:param threshold: if the dropmethod is set to 'threshold', any peaks below this threshold will be dropped.
|
| 841 |
+
:param npeaks: if the dropmethod is set to 'npeaks', the top n peaks will be kept, with the rest being dropped.
|
| 842 |
+
:param consolidate: if the dropmethod is set to 'consolidate', any peaks below the threshold will be consolidated
|
| 843 |
+
into adjacent peaks using a weighted average. Any peaks that do not have a neighbour within 10^-`consolidate`
|
| 844 |
+
will be dropped entirely.
|
| 845 |
+
:return: Returns the isotope pattern with mass defects preserved (referred to as the 'raw'
|
| 846 |
+
isotope pattern in this script).
|
| 847 |
+
:rtype: Spectrum
|
| 848 |
+
"""
|
| 849 |
+
spec = None # initial state of spec
|
| 850 |
+
if verbose is True:
|
| 851 |
+
sys.stdout.write('Generating raw isotope pattern.\n')
|
| 852 |
+
|
| 853 |
+
for key in comp: # for each element
|
| 854 |
+
if key in mass_dict: # if not a single isotope
|
| 855 |
+
if verbose is True:
|
| 856 |
+
prog = Progress(string=f'Processing element {key}', last=comp[key])
|
| 857 |
+
masses = [] # list for masses of each isotope
|
| 858 |
+
abunds = [] # list for abundances
|
| 859 |
+
for mass in mass_dict[key]:
|
| 860 |
+
if mass != 0:
|
| 861 |
+
if mass_dict[key][mass][1] > 0: # if abundance is nonzero
|
| 862 |
+
masses.append(mass_dict[key][mass][0])
|
| 863 |
+
abunds.append(mass_dict[key][mass][1])
|
| 864 |
+
for n in range(comp[key]): # for n number of each element
|
| 865 |
+
if verbose is True:
|
| 866 |
+
prog.write(n + 1)
|
| 867 |
+
if spec is None: # if spectrum object has not been defined
|
| 868 |
+
spec = Spectrum(
|
| 869 |
+
decpl, # decimal places
|
| 870 |
+
start=min(masses) - 10 ** -decpl, # minimum mass
|
| 871 |
+
end=max(masses) + 10 ** -decpl, # maximum mass
|
| 872 |
+
specin=[masses, abunds], # supply masses and abundances as initialization spectrum
|
| 873 |
+
empty=True, # whether or not to use emptyspec
|
| 874 |
+
filler=0., # fill with zeros, not None
|
| 875 |
+
)
|
| 876 |
+
continue
|
| 877 |
+
spec.add_element(masses, abunds) # add the element to the spectrum object
|
| 878 |
+
spec.normalize(100.) # normalize spectrum
|
| 879 |
+
if dropmethod == 'threshold': # drop values below threshold
|
| 880 |
+
spec.threshold(threshold)
|
| 881 |
+
elif dropmethod == 'npeaks': # keep top n number of peaks
|
| 882 |
+
spec.keep_top_n(npeaks)
|
| 883 |
+
elif dropmethod == 'consolidate': # consolidate values being dropped
|
| 884 |
+
# todo figure out what's wrong here
|
| 885 |
+
raise NotImplementedError("There are bugs here, for the time being don't use the 'consolidate' "
|
| 886 |
+
"dropmethod.")
|
| 887 |
+
spec.consolidate(
|
| 888 |
+
threshold,
|
| 889 |
+
3 * 10 ** -consolidate
|
| 890 |
+
)
|
| 891 |
+
else: # if specific isotope
|
| 892 |
+
ele, iso = string_to_isotope(key) # find element and isotope
|
| 893 |
+
if verbose is True:
|
| 894 |
+
prog = Progress(string=f'Processing isotope {key}', fraction=False, percent=False)
|
| 895 |
+
if spec is None: # if spectrum object has not been defined
|
| 896 |
+
spec = Spectrum(
|
| 897 |
+
decpl, # decimal places
|
| 898 |
+
start=(mass_dict[ele][iso][0] * float(comp[key])) - 10 ** -decpl, # minimum mass
|
| 899 |
+
end=(mass_dict[ele][iso][0] * float(comp[key])) + 10 ** -decpl, # maximum mass
|
| 900 |
+
specin=[[mass_dict[ele][iso][0] * float(comp[key])], [1.]],
|
| 901 |
+
# supply masses and abundances as initialization spectrum
|
| 902 |
+
empty=True, # whether or not to use emptyspec
|
| 903 |
+
filler=0. # fill with zeros, not None
|
| 904 |
+
)
|
| 905 |
+
continue
|
| 906 |
+
spec.shift_x(mass_dict[ele][iso][0]) # offset spectrum object by the mass of that
|
| 907 |
+
if verbose is True:
|
| 908 |
+
prog.fin(' ')
|
| 909 |
+
spec.normalize()
|
| 910 |
+
if verbose is True:
|
| 911 |
+
sys.stdout.write('DONE\n')
|
| 912 |
+
return spec
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
def isotope_pattern_isospec(
|
| 916 |
+
comp: dict,
|
| 917 |
+
decpl: int,
|
| 918 |
+
verbose: bool = VERBOSE,
|
| 919 |
+
threshold: float = THRESHOLD,
|
| 920 |
+
**kwargs,
|
| 921 |
+
):
|
| 922 |
+
"""
|
| 923 |
+
Generates a raw isotope pattern using the isospecpy package. http://matteolacki.github.io/IsoSpec/
|
| 924 |
+
|
| 925 |
+
:param comp: composition dictionary
|
| 926 |
+
:param decpl: decimal places to track while converting from isospec to Spectrum
|
| 927 |
+
:param verbose: chatty mode
|
| 928 |
+
:param threshold: threshold level (relative, seems slightly buggy)
|
| 929 |
+
:param kwargs: catch for extra kwargs
|
| 930 |
+
:return: Spectrum object
|
| 931 |
+
"""
|
| 932 |
+
global _CITATION_REMINDER
|
| 933 |
+
if _CITATION_REMINDER is False: # remind the user on the first use
|
| 934 |
+
print('IsoSpecPy package was used, please cite https://dx.doi.org/10.1021/acs.analchem.6b01459')
|
| 935 |
+
_CITATION_REMINDER = True
|
| 936 |
+
|
| 937 |
+
if any([key not in mass_dict for key in comp]):
|
| 938 |
+
# todo see if there's a workaround for isotope specification
|
| 939 |
+
raise KeyError(f'Isotope specification is not supported in IsoSpec calling. Please use a different isotope '
|
| 940 |
+
f'pattern generation method for isotopes. ')
|
| 941 |
+
|
| 942 |
+
# todo see if there's a way to use IsoThresholdGenerator instead
|
| 943 |
+
# use IsoSpec algorithm to generate configurations
|
| 944 |
+
iso_spec = IsoThreshold(
|
| 945 |
+
formula="".join(f'{ele}{num}' for ele, num in comp.items()),
|
| 946 |
+
threshold=threshold * 0.1,
|
| 947 |
+
)
|
| 948 |
+
|
| 949 |
+
spec = Spectrum(
|
| 950 |
+
decpl, # decimal places
|
| 951 |
+
start=min(iso_spec.masses) - 10 ** -decpl, # minimum mass
|
| 952 |
+
end=max(iso_spec.masses) + 10 ** -decpl, # maximum mass
|
| 953 |
+
empty=True,
|
| 954 |
+
filler=0. # fill with zeros, not None
|
| 955 |
+
)
|
| 956 |
+
# add values to Spectrum object
|
| 957 |
+
for mass, abund in zip(iso_spec.masses, iso_spec.probs):
|
| 958 |
+
spec.add_value(
|
| 959 |
+
mass,
|
| 960 |
+
abund
|
| 961 |
+
)
|
| 962 |
+
spec.normalize() # normalize values to 100.
|
| 963 |
+
return spec
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
def pattern_molecular_weight(mzs: list, intensities: list, charge: int = 1):
|
| 967 |
+
"""
|
| 968 |
+
Calculates the molecular weight given by an isotope pattern.
|
| 969 |
+
|
| 970 |
+
:param mzs: m/z (x) values for pattern
|
| 971 |
+
:param intensities: intensity (y) values for the pattern
|
| 972 |
+
:param charge: charge for the molecule
|
| 973 |
+
:return: molecular weight
|
| 974 |
+
:rtype: float
|
| 975 |
+
"""
|
| 976 |
+
return sum([ # sum
|
| 977 |
+
mz * intensity * charge # of the product of the m/z, intensity, and charge
|
| 978 |
+
for mz, intensity in zip(mzs, intensities) # for all the values
|
| 979 |
+
]) / sum(intensities) # divided by the sum of the intensities
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
def molecular_weight_error(calculated: float, expected: float):
|
| 983 |
+
"""
|
| 984 |
+
Calculate the error between a calculated and expected molecular weight. This method may be used as a validation
|
| 985 |
+
tool for calculated isotope patterns.
|
| 986 |
+
|
| 987 |
+
:param calculated: calculated molecular weight (derived from an isotope pattern)
|
| 988 |
+
:param expected: expected (true) molecular weight (derived from the molecular weights of the constituent elements)
|
| 989 |
+
:return: Calculated error. Typically a difference of 3 parts per million (3*10^-6) is deemed an acceptable
|
| 990 |
+
error.
|
| 991 |
+
:rtype: float
|
| 992 |
+
"""
|
| 993 |
+
return (calculated - expected) / expected
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
class Molecule(object):
|
| 997 |
+
_comp = {} # storage for composition of the molecule
|
| 998 |
+
_mf = ''
|
| 999 |
+
verbose = VERBOSE
|
| 1000 |
+
|
| 1001 |
+
def __init__(self,
|
| 1002 |
+
string: (str, dict),
|
| 1003 |
+
charge=1,
|
| 1004 |
+
mass_key='nist_mass',
|
| 1005 |
+
verbose=False,
|
| 1006 |
+
):
|
| 1007 |
+
"""
|
| 1008 |
+
Calculates many properties of a specified molecule.
|
| 1009 |
+
|
| 1010 |
+
:param str, dict string: The molecule to interpret. A composition dictionary may also be specified here.
|
| 1011 |
+
:param int, str charge: the charge of the molecule (for mass spectrometric applications).
|
| 1012 |
+
This will affect any properties related to the mass to charge
|
| 1013 |
+
ratio. If the charge is specified in the input molecular formula, this will be
|
| 1014 |
+
overridden.
|
| 1015 |
+
:param str mass_key: The mass dictionary to use for calculations. Default is nist_mass, but additional mass
|
| 1016 |
+
dictionaries may be defined in the mass_dictionary file and retrieved using the dictionary name
|
| 1017 |
+
used to define them.
|
| 1018 |
+
:param bool verbose: Verbose output. Mostly useful when calculating for large molecules or while debugging.
|
| 1019 |
+
|
| 1020 |
+
**Notes regarding string specification**
|
| 1021 |
+
|
| 1022 |
+
- Common abbreviations may be predefined in mass_abbreviations.py (either locally or in the current working
|
| 1023 |
+
directory)
|
| 1024 |
+
|
| 1025 |
+
- Use brackets to signify multiples of a given component (nested brackets are supported)
|
| 1026 |
+
|
| 1027 |
+
- Isotopes may be specified using an isotope-element format within a bracket (e.g. carbon 13 would be specified
|
| 1028 |
+
as "(13C)" ). The mass of that isotope must be defined in the mass dictionary being used by the script
|
| 1029 |
+
(default NIST mass).
|
| 1030 |
+
|
| 1031 |
+
- The charge may be specified in the formula, but care must be taken here. Charge must be specified in either
|
| 1032 |
+
sign-value (e.g. '+2') or within a bracket. Otherwise, the script may attempt to interpret the charge as a
|
| 1033 |
+
magnitude specifier of the previous block or as an isotope, and errors will be encountered.
|
| 1034 |
+
|
| 1035 |
+
- A composition dictionary with the format `{'Element': number_of_that_element, ...}` may be provided instead
|
| 1036 |
+
of a string formula
|
| 1037 |
+
|
| 1038 |
+
"""
|
| 1039 |
+
if verbose is True:
|
| 1040 |
+
sys.stdout.write(f'Generating molecule object from input {string}\n')
|
| 1041 |
+
# split charge into value and sign
|
| 1042 |
+
self.charge, self.sign = interpret_charge(charge)
|
| 1043 |
+
self.mass_key = mass_key # store mass dictionary that the script will use
|
| 1044 |
+
self.verbose = verbose
|
| 1045 |
+
if type(string) == dict: # if a composition dictionary was provided
|
| 1046 |
+
self.composition = string
|
| 1047 |
+
elif type(string) == str: # set string and interpret formula
|
| 1048 |
+
self.molecular_formula = string
|
| 1049 |
+
else:
|
| 1050 |
+
raise TypeError(f'The provided string type is not interpretable: {type(string)}')
|
| 1051 |
+
|
| 1052 |
+
if self.verbose is True:
|
| 1053 |
+
self.print_details()
|
| 1054 |
+
|
| 1055 |
+
def __repr__(self):
|
| 1056 |
+
return f'{self.__class__.__name__}({self.molecular_formula})'
|
| 1057 |
+
|
| 1058 |
+
def __str__(self):
|
| 1059 |
+
return self.__repr__()
|
| 1060 |
+
|
| 1061 |
+
def __contains__(self, item):
|
| 1062 |
+
if type(item) == str:
|
| 1063 |
+
return item in self._comp
|
| 1064 |
+
elif type(item) == list or type(item) == tuple:
|
| 1065 |
+
return all([element in self._comp for element in item])
|
| 1066 |
+
elif type(item) == dict:
|
| 1067 |
+
return all([
|
| 1068 |
+
element in self._comp and self._comp[element] >= num for element, num in item.items()
|
| 1069 |
+
])
|
| 1070 |
+
elif isinstance(item, Molecule):
|
| 1071 |
+
return self.__contains__(item.composition)
|
| 1072 |
+
else:
|
| 1073 |
+
raise TypeError(f'The item {item} is not a recognized type for containment checks. Type: {type(item)}')
|
| 1074 |
+
|
| 1075 |
+
def __iter__(self):
|
| 1076 |
+
for element in self._comp:
|
| 1077 |
+
yield element
|
| 1078 |
+
|
| 1079 |
+
def __getitem__(self, item):
|
| 1080 |
+
return self._comp[item]
|
| 1081 |
+
|
| 1082 |
+
def __eq__(self, other):
|
| 1083 |
+
if type(other) == dict:
|
| 1084 |
+
return other == self._comp
|
| 1085 |
+
elif isinstance(other, Molecule):
|
| 1086 |
+
return other.composition == self._comp
|
| 1087 |
+
return False
|
| 1088 |
+
|
| 1089 |
+
def __ne__(self, other):
|
| 1090 |
+
return not self.__eq__(other)
|
| 1091 |
+
|
| 1092 |
+
def __lt__(self, other):
|
| 1093 |
+
if type(other) == dict:
|
| 1094 |
+
return all([
|
| 1095 |
+
number < self._comp[element] for element, number in other.items()
|
| 1096 |
+
])
|
| 1097 |
+
elif isinstance(other, Molecule):
|
| 1098 |
+
return all([
|
| 1099 |
+
number < self._comp[element] for element, number in other.composition.items()
|
| 1100 |
+
])
|
| 1101 |
+
else:
|
| 1102 |
+
raise TypeError(f'Comparison of type {type(other)} to {self.__class__} is unsupported. ')
|
| 1103 |
+
|
| 1104 |
+
def __le__(self, other):
|
| 1105 |
+
return self.__eq__(other) or self.__lt__(other)
|
| 1106 |
+
|
| 1107 |
+
def __gt__(self, other):
|
| 1108 |
+
if type(other) == dict:
|
| 1109 |
+
return all([
|
| 1110 |
+
number > self._comp[element] for element, number in other.items()
|
| 1111 |
+
])
|
| 1112 |
+
elif isinstance(other, Molecule):
|
| 1113 |
+
return all([
|
| 1114 |
+
number > self._comp[element] for element, number in other.composition.items()
|
| 1115 |
+
])
|
| 1116 |
+
else:
|
| 1117 |
+
raise TypeError(f'Comparison to type {type(other)} to {self.__class__} is unsupported. ')
|
| 1118 |
+
|
| 1119 |
+
def __ge__(self, other):
|
| 1120 |
+
return self.__eq__(other) or self.__gt__(other)
|
| 1121 |
+
|
| 1122 |
+
def __getinitargs__(self):
|
| 1123 |
+
return (
|
| 1124 |
+
self.composition,
|
| 1125 |
+
f'{self.charge}{self.sign}',
|
| 1126 |
+
self.mass_key,
|
| 1127 |
+
self.verbose,
|
| 1128 |
+
)
|
| 1129 |
+
|
| 1130 |
+
def __reduce__(self):
|
| 1131 |
+
"""pickle support"""
|
| 1132 |
+
return (
|
| 1133 |
+
self.__class__,
|
| 1134 |
+
self.__getinitargs__(),
|
| 1135 |
+
)
|
| 1136 |
+
|
| 1137 |
+
def __add__(self, other):
|
| 1138 |
+
"""
|
| 1139 |
+
Several supported addition methods:
|
| 1140 |
+
If a valid molecular formula string is provided, that string will be added.
|
| 1141 |
+
If another Molecule class instance is provided, the provided instance will be
|
| 1142 |
+
added to the current instance.
|
| 1143 |
+
"""
|
| 1144 |
+
if type(other) is str:
|
| 1145 |
+
other = composition_from_formula(other)
|
| 1146 |
+
elif isinstance(other, Molecule) is True:
|
| 1147 |
+
other = other.composition
|
| 1148 |
+
elif type(other) == dict:
|
| 1149 |
+
pass
|
| 1150 |
+
else:
|
| 1151 |
+
raise ValueError(f'Addition of {other} to {self} is invalid')
|
| 1152 |
+
new = copy.copy(self._comp) # starter for new dictionary
|
| 1153 |
+
|
| 1154 |
+
for key in other:
|
| 1155 |
+
try:
|
| 1156 |
+
new[key] += other[key]
|
| 1157 |
+
except KeyError:
|
| 1158 |
+
new[key] = other[key]
|
| 1159 |
+
return self.__class__(
|
| 1160 |
+
new,
|
| 1161 |
+
charge=f'{self.charge}{self.sign}'
|
| 1162 |
+
)
|
| 1163 |
+
|
| 1164 |
+
def __radd__(self, other):
|
| 1165 |
+
return self.__add__(other)
|
| 1166 |
+
|
| 1167 |
+
def __iadd__(self, other):
|
| 1168 |
+
if type(other) is str:
|
| 1169 |
+
other = composition_from_formula(other)
|
| 1170 |
+
elif isinstance(other, Molecule) is True:
|
| 1171 |
+
other = other.composition
|
| 1172 |
+
elif type(other) == dict:
|
| 1173 |
+
pass
|
| 1174 |
+
else:
|
| 1175 |
+
raise ValueError(f'Addition of {other} to {self} is invalid')
|
| 1176 |
+
new = copy.copy(self._comp) # starter for new dictionary
|
| 1177 |
+
for key in other:
|
| 1178 |
+
try:
|
| 1179 |
+
new[key] += other[key]
|
| 1180 |
+
except KeyError:
|
| 1181 |
+
new[key] = other[key]
|
| 1182 |
+
self.composition = new
|
| 1183 |
+
return self
|
| 1184 |
+
|
| 1185 |
+
def __sub__(self, other):
|
| 1186 |
+
"""
|
| 1187 |
+
See __add__ for details.
|
| 1188 |
+
Subtract has a catch for a negative number of a given element
|
| 1189 |
+
(the minimum that can be reached is zero).
|
| 1190 |
+
"""
|
| 1191 |
+
if type(other) is str:
|
| 1192 |
+
other = composition_from_formula(other)
|
| 1193 |
+
elif isinstance(other, Molecule) is True:
|
| 1194 |
+
other = other.composition
|
| 1195 |
+
elif type(other) == dict:
|
| 1196 |
+
pass
|
| 1197 |
+
else:
|
| 1198 |
+
raise ValueError(f'Addition of {other} to {self} is invalid')
|
| 1199 |
+
new = copy.copy(self._comp) # starter for new dictionary
|
| 1200 |
+
|
| 1201 |
+
for key in other:
|
| 1202 |
+
if new[key] - other[key] < 0 or key not in new:
|
| 1203 |
+
raise ValueError('Subtraction of {other[key]} {key} from {self} would yield a negative number of that '
|
| 1204 |
+
'element.')
|
| 1205 |
+
new[key] -= other[key]
|
| 1206 |
+
return self.__class__(
|
| 1207 |
+
new,
|
| 1208 |
+
charge=f'{self.charge}{self.sign}'
|
| 1209 |
+
)
|
| 1210 |
+
|
| 1211 |
+
def __rsub__(self, other):
|
| 1212 |
+
return self.__sub__(other)
|
| 1213 |
+
|
| 1214 |
+
def __isub__(self, other):
|
| 1215 |
+
if type(other) is str:
|
| 1216 |
+
other = composition_from_formula(other)
|
| 1217 |
+
elif isinstance(other, Molecule) is True:
|
| 1218 |
+
other = other.composition
|
| 1219 |
+
elif type(other) == dict:
|
| 1220 |
+
pass
|
| 1221 |
+
else:
|
| 1222 |
+
raise ValueError(f'Addition of {other} to {self} is invalid')
|
| 1223 |
+
new = copy.copy(self._comp) # starter for new dictionary
|
| 1224 |
+
|
| 1225 |
+
for key in other:
|
| 1226 |
+
if new[key] - other[key] < 0 or key not in new:
|
| 1227 |
+
raise ValueError('Subtraction of {other[key]} {key} from {self} would yield a negative number of that '
|
| 1228 |
+
'element.')
|
| 1229 |
+
new[key] -= other[key]
|
| 1230 |
+
self.composition = new
|
| 1231 |
+
return self
|
| 1232 |
+
|
| 1233 |
+
def __mul__(self, other):
|
| 1234 |
+
"""allows integer multiplication of the molecular formula"""
|
| 1235 |
+
if type(other) != int:
|
| 1236 |
+
raise ValueError(f'Non-integer multiplication of a {self.__class__.__name__} object is unsupported')
|
| 1237 |
+
new = copy.copy(self._comp) # starter for new dictionary
|
| 1238 |
+
for key in new:
|
| 1239 |
+
new[key] = new[key] * other
|
| 1240 |
+
return self.__class__(
|
| 1241 |
+
new,
|
| 1242 |
+
charge=f'{self.charge}{self.sign}'
|
| 1243 |
+
)
|
| 1244 |
+
|
| 1245 |
+
def __rmul__(self, other):
|
| 1246 |
+
return self.__mul__(other)
|
| 1247 |
+
|
| 1248 |
+
def __imul__(self, other):
|
| 1249 |
+
if type(other) != int:
|
| 1250 |
+
raise ValueError(f'Non-integer multiplication of a {self.__class__.__name__} object is unsupported')
|
| 1251 |
+
new = copy.copy(self._comp) # starter for new dictionary
|
| 1252 |
+
for key in new:
|
| 1253 |
+
new[key] = new[key] * other
|
| 1254 |
+
self.composition = new
|
| 1255 |
+
return self
|
| 1256 |
+
|
| 1257 |
+
def __truediv__(self, other):
|
| 1258 |
+
"""allows integer division of the molecular formula"""
|
| 1259 |
+
if type(other) != int:
|
| 1260 |
+
raise ValueError(f'Non-integer division of a {self.__class__.__name__} object is unsupported')
|
| 1261 |
+
new = copy.copy(self._comp) # starter for new dictionary
|
| 1262 |
+
for key in new:
|
| 1263 |
+
newval = new[key] / other
|
| 1264 |
+
if newval.is_integer() is False:
|
| 1265 |
+
raise ValueError(f'Division of {new[key]} {key} by {other} yielded a non-integer number {newval}')
|
| 1266 |
+
new[key] = int(newval)
|
| 1267 |
+
return self.__class__(
|
| 1268 |
+
new,
|
| 1269 |
+
charge=f'{self.charge}{self.sign}'
|
| 1270 |
+
)
|
| 1271 |
+
|
| 1272 |
+
def __itruediv__(self, other):
|
| 1273 |
+
if type(other) != int:
|
| 1274 |
+
raise ValueError(f'Non-integer division of a {self.__class__.__name__} object is unsupported')
|
| 1275 |
+
new = copy.copy(self._comp) # starter for new dictionary
|
| 1276 |
+
for key in new:
|
| 1277 |
+
newval = new[key] / other
|
| 1278 |
+
if newval.is_integer() is False:
|
| 1279 |
+
raise ValueError(f'Division of {new[key]} {key} by {other} yielded a non-integer number {newval}')
|
| 1280 |
+
new[key] = int(newval)
|
| 1281 |
+
self.composition = new
|
| 1282 |
+
return self
|
| 1283 |
+
|
| 1284 |
+
@property
|
| 1285 |
+
def composition(self):
|
| 1286 |
+
"""Composition dictionary"""
|
| 1287 |
+
return self._comp
|
| 1288 |
+
|
| 1289 |
+
@composition.setter
|
| 1290 |
+
def composition(self, dct):
|
| 1291 |
+
if type(dct) != dict:
|
| 1292 |
+
raise TypeError('The composition must be a dictionary')
|
| 1293 |
+
dct = copy.copy(dct)
|
| 1294 |
+
dct = abbreviations(dct) # check for and convert abbreviations
|
| 1295 |
+
if 'charge' in dct: # if charge was specified in the formula
|
| 1296 |
+
self.charge, self.sign = interpret_charge(dct['charge'])
|
| 1297 |
+
del dct['charge']
|
| 1298 |
+
check_in_mass_dict(dct) # check in mass dictionary
|
| 1299 |
+
self._comp = dct # set local dictionary
|
| 1300 |
+
|
| 1301 |
+
@property
|
| 1302 |
+
def molecular_formula(self):
|
| 1303 |
+
"""Molecular formula of the molecule"""
|
| 1304 |
+
out = ''
|
| 1305 |
+
# todo catch carbon and hydrogen isotopes first
|
| 1306 |
+
if 'C' in self.composition: # carbon and hydrogen first according to hill formula
|
| 1307 |
+
out += f'C{self.composition["C"]}' if self.composition['C'] > 1 else 'C'
|
| 1308 |
+
if 'H' in self.composition:
|
| 1309 |
+
out += f'H{self.composition["H"]}' if self.composition['H'] > 1 else 'H'
|
| 1310 |
+
for key, val in sorted(self.composition.items()): # alphabetically otherwise
|
| 1311 |
+
if key != 'C' and key != 'H':
|
| 1312 |
+
if key in mass_dict:
|
| 1313 |
+
out += f'{key}{self.composition[key]}' if self.composition[key] > 1 else f'{key}'
|
| 1314 |
+
else: # if an isotope
|
| 1315 |
+
ele, iso = string_to_isotope(key)
|
| 1316 |
+
out += f'({iso}{ele})'
|
| 1317 |
+
out += f'{self.composition[key]}' if self.composition[key] > 1 else ''
|
| 1318 |
+
return out
|
| 1319 |
+
|
| 1320 |
+
@molecular_formula.setter
|
| 1321 |
+
def molecular_formula(self, formula):
|
| 1322 |
+
self.composition = composition_from_formula(formula)
|
| 1323 |
+
self._mf = formula
|
| 1324 |
+
|
| 1325 |
+
@property
|
| 1326 |
+
def molecular_formula_formatted(self):
|
| 1327 |
+
"""returns the subscript-formatted molecular formula"""
|
| 1328 |
+
out = ''
|
| 1329 |
+
if 'C' in self.composition:
|
| 1330 |
+
out += f'C{to_subscript(self.composition["C"]) if self.composition["C"] > 1 else "C"}'
|
| 1331 |
+
if 'H' in self.composition:
|
| 1332 |
+
out += f'H{to_subscript(self.composition["H"]) if self.composition["H"] > 1 else "H"}'
|
| 1333 |
+
for key, val in sorted(self.composition.items()):
|
| 1334 |
+
if key not in ['C', 'H']:
|
| 1335 |
+
if key in mass_dict:
|
| 1336 |
+
out += f'{key}{to_subscript(self.composition[key])}' if self.composition[key] > 1 else f'{key}'
|
| 1337 |
+
else:
|
| 1338 |
+
ele, iso = string_to_isotope(key)
|
| 1339 |
+
out += f'{to_superscript(iso)}{ele}'
|
| 1340 |
+
out += f'{to_subscript(self.composition[key])}' if self.composition[key] > 1 else ''
|
| 1341 |
+
return out
|
| 1342 |
+
|
| 1343 |
+
@property
|
| 1344 |
+
def sf(self):
|
| 1345 |
+
"""legacy catch for shorthand 'string formula' attribute"""
|
| 1346 |
+
return self.molecular_formula
|
| 1347 |
+
|
| 1348 |
+
@property
|
| 1349 |
+
def molecular_weight(self):
|
| 1350 |
+
"""Molecular weight of the molecule"""
|
| 1351 |
+
mwout = 0
|
| 1352 |
+
for element, number in self.composition.items():
|
| 1353 |
+
try:
|
| 1354 |
+
mass = mass_dict[element]
|
| 1355 |
+
for isotope in mass:
|
| 1356 |
+
if isotope == 0:
|
| 1357 |
+
continue
|
| 1358 |
+
# add every isotope times its natural abundance times the number of that element
|
| 1359 |
+
mwout += mass[isotope][0] * mass[isotope][1] * number
|
| 1360 |
+
except KeyError: # if isotope
|
| 1361 |
+
ele, iso = string_to_isotope(element)
|
| 1362 |
+
mwout += mass_dict[ele][iso][0] * number # assumes 100% abundance if specified
|
| 1363 |
+
return mwout
|
| 1364 |
+
|
| 1365 |
+
@property
|
| 1366 |
+
def mw(self):
|
| 1367 |
+
"""legacy catch for shorthand molecular weight"""
|
| 1368 |
+
return self.molecular_weight
|
| 1369 |
+
|
| 1370 |
+
@property
|
| 1371 |
+
def percent_composition(self):
|
| 1372 |
+
"""Elemental percent composition of the molecule"""
|
| 1373 |
+
pcompout = {} # percent composition dictionary
|
| 1374 |
+
for element, number in self.composition.items():
|
| 1375 |
+
try:
|
| 1376 |
+
mass = mass_dict[element]
|
| 1377 |
+
for isotope in mass:
|
| 1378 |
+
if isotope == 0:
|
| 1379 |
+
continue
|
| 1380 |
+
if element not in pcompout:
|
| 1381 |
+
pcompout[element] = 0.
|
| 1382 |
+
# add mass contributed by that element
|
| 1383 |
+
pcompout[element] += mass[isotope][0] * mass[isotope][1] * number
|
| 1384 |
+
except KeyError: # if isotope
|
| 1385 |
+
ele, iso = string_to_isotope(element)
|
| 1386 |
+
pcompout[str(iso) + ele] = mass_dict[ele][iso][0] * number
|
| 1387 |
+
mw = self.molecular_weight
|
| 1388 |
+
for element in pcompout: # determines the percent composition of each element
|
| 1389 |
+
try:
|
| 1390 |
+
pcompout[element] = pcompout[element] / mw
|
| 1391 |
+
except ZeroDivisionError:
|
| 1392 |
+
pcompout[element] = 0.
|
| 1393 |
+
return pcompout
|
| 1394 |
+
|
| 1395 |
+
@property
|
| 1396 |
+
def pcomp(self):
|
| 1397 |
+
"""legacy catch for shorthand percent composition"""
|
| 1398 |
+
return self.percent_composition
|
| 1399 |
+
|
| 1400 |
+
@property
|
| 1401 |
+
def monoisotopic_mass(self):
|
| 1402 |
+
"""An estimation of the exact mass given by the molecular formula. This is likely not accurate for high-mass
|
| 1403 |
+
species"""
|
| 1404 |
+
em = 0.
|
| 1405 |
+
for element, number in self.composition.items():
|
| 1406 |
+
try:
|
| 1407 |
+
em += mass_dict[element][0][0] * number
|
| 1408 |
+
except KeyError:
|
| 1409 |
+
ele, iso = string_to_isotope(element)
|
| 1410 |
+
em += mass_dict[ele][iso][0] * number
|
| 1411 |
+
# # accounts for the mass of an electron (uncomment if this affects your data)
|
| 1412 |
+
# if self.sign == '+':
|
| 1413 |
+
# em -= (9.10938356*10**-28)*charge
|
| 1414 |
+
# if self.sign == '-':
|
| 1415 |
+
# em += (9.10938356*10**-28)*charge
|
| 1416 |
+
return em / self.charge
|
| 1417 |
+
|
| 1418 |
+
@property
|
| 1419 |
+
def standard_deviation_comp(self):
|
| 1420 |
+
"""
|
| 1421 |
+
cacluates the standard deviation of the isotope pattern of the supplied composition
|
| 1422 |
+
this calculation is based on Rockwood and Van Orden 1996 doi: 10.1021/ac951158i
|
| 1423 |
+
"""
|
| 1424 |
+
stdev = 0.
|
| 1425 |
+
for element, number in self.composition.items():
|
| 1426 |
+
meanmass = 0
|
| 1427 |
+
eledev = 0 # elemental deviation
|
| 1428 |
+
mass = mass_dict[element]
|
| 1429 |
+
for isotope in mass: # calculate weighted average mass
|
| 1430 |
+
if isotope != 0:
|
| 1431 |
+
meanmass += sum(mass[isotope]) # weighted average mass
|
| 1432 |
+
for isotope in mass:
|
| 1433 |
+
if mass != 0:
|
| 1434 |
+
eledev += mass[isotope][1] * (mass[isotope][0] - meanmass) ** 2
|
| 1435 |
+
stdev += eledev * number
|
| 1436 |
+
return np.sqrt(stdev)
|
| 1437 |
+
|
| 1438 |
+
def print_details(self):
|
| 1439 |
+
"""prints the details of the generated molecule"""
|
| 1440 |
+
sys.stdout.write(f'{self}\n')
|
| 1441 |
+
sys.stdout.write(f'formula: {self.molecular_formula}\n')
|
| 1442 |
+
sys.stdout.write(f'molecular weight: {round(self.molecular_weight, 6)}\n')
|
| 1443 |
+
sys.stdout.write(f'monoisotopic mass: {round(self.monoisotopic_mass, 6)}\n')
|
| 1444 |
+
sys.stdout.write('\n')
|
| 1445 |
+
self.print_percent_composition()
|
| 1446 |
+
|
| 1447 |
+
def print_percent_composition(self):
|
| 1448 |
+
"""prints the percent composition in a reader-friendly format"""
|
| 1449 |
+
sys.stdout.write('elemental percent composition:\n')
|
| 1450 |
+
pcomp = self.percent_composition
|
| 1451 |
+
for element, percent in sorted(pcomp.items()):
|
| 1452 |
+
sys.stdout.write(f'{element}: {percent * 100.:6.4}%\n')
|
| 1453 |
+
|
| 1454 |
+
|
| 1455 |
+
class IPMolecule(Molecule):
|
| 1456 |
+
_ipmethod = None
|
| 1457 |
+
_gausip = None # gaussian isotope pattern storage
|
| 1458 |
+
_dropmethod = None
|
| 1459 |
+
|
| 1460 |
+
def __init__(self,
|
| 1461 |
+
string: (str, dict),
|
| 1462 |
+
charge=1,
|
| 1463 |
+
consolidate=3,
|
| 1464 |
+
criticalerror=3 * 10 ** -6,
|
| 1465 |
+
decpl=7,
|
| 1466 |
+
dropmethod=None,
|
| 1467 |
+
emptyspec=True,
|
| 1468 |
+
groupmethod='weighted',
|
| 1469 |
+
ipmethod='hybrid',
|
| 1470 |
+
keepall=False,
|
| 1471 |
+
npeaks=5000,
|
| 1472 |
+
resolution=5000,
|
| 1473 |
+
threshold=0.01,
|
| 1474 |
+
save=False,
|
| 1475 |
+
verbose=VERBOSE,
|
| 1476 |
+
precalculated=None,
|
| 1477 |
+
):
|
| 1478 |
+
"""
|
| 1479 |
+
A class with many mass-spectrometric properties such as estimated exact masses, isotope patterns, error
|
| 1480 |
+
estimators, and basic plotting tools.
|
| 1481 |
+
|
| 1482 |
+
:param str string: the molecule name to interpret. See Molecule documentation for more details
|
| 1483 |
+
:param int, str charge: the charge of the molecule (for mass spectrometric applications).
|
| 1484 |
+
This will affect any properties related to the mass to charge
|
| 1485 |
+
ratio. If the charge is specified in the input molecular formula, this will be
|
| 1486 |
+
overridden.
|
| 1487 |
+
|
| 1488 |
+
:param int, float resolution: The resolution of the instrument to simulate when generating the gaussian isotope
|
| 1489 |
+
pattern. This also affects the bounds attribute.
|
| 1490 |
+
|
| 1491 |
+
:param int consolidate: When using the consolidate drop method, consolidate peaks within 10^-*consolidate*
|
| 1492 |
+
of each other. See *dropmethod* for more details.
|
| 1493 |
+
|
| 1494 |
+
:param float criticalerror:
|
| 1495 |
+
The critical error value used for warning the user of a potential calculation error.
|
| 1496 |
+
This only affects the ``print_details()`` function output. Default 3*10**-6 (3 parts per million)
|
| 1497 |
+
|
| 1498 |
+
:param int decpl: The number of decimal places to track while calculating the isotope pattern.
|
| 1499 |
+
Decreasing this will improve efficiency but decrease accuracy. Options: integer.
|
| 1500 |
+
|
| 1501 |
+
:param 'threshold', 'npeaks', 'consolidate' dropmethod: The peak drop method to use if desired.
|
| 1502 |
+
Using a peak dropping method will improve calculation times, but decrease the accuracy of the
|
| 1503 |
+
calculated isotope pattern. 'threshold' drops all peaks below a specified threshold value (specified using
|
| 1504 |
+
the *threshold* keyword argument). 'npeaks' keeps the top *n* peaks, specified by the *npeaks* keyword
|
| 1505 |
+
argument. 'consolidate' combines the intensity of peaks below the threshold value into the
|
| 1506 |
+
nearest peak (within the delta specified by the *consolidate* keyword argument, this method is the most
|
| 1507 |
+
accurate). The new peak *m/z* value is determined by the weighted average of the combined peaks. This will
|
| 1508 |
+
be repeated until the peak is above the threshold or there are no near peaks.
|
| 1509 |
+
|
| 1510 |
+
:param bool emptyspec: Whether to use an empty spectrum obect. Disable this for very large molecules to
|
| 1511 |
+
improve calculation time.
|
| 1512 |
+
|
| 1513 |
+
:param 'weighted', 'centroid' groupmethod: The grouping method to use when calculating the bar isotope pattern
|
| 1514 |
+
from the raw isotope pattern. Weighted calculates the peak locations using the weighted average of the *m/z*
|
| 1515 |
+
and intensity values. Centroid finds the center *m/z* value of a group of peaks.
|
| 1516 |
+
|
| 1517 |
+
:param 'multiplicative', 'combinatorial', 'hybrid', 'cuda', ipmethod: The method to use for determining the isotope
|
| 1518 |
+
pattern. 'multiplicative' multiplies the existing list of intensities by each element. 'combinatorial' uses
|
| 1519 |
+
combinatorics and iterators to calculate each possible combination. 'hybrid' uses combinatorics to calcuate
|
| 1520 |
+
the pattern from each element, then multiplies those together
|
| 1521 |
+
|
| 1522 |
+
:param bool keepall: Whether to keep all peaks calculated in the isotope pattern. When false, this will drop
|
| 1523 |
+
all intensities below 0.0001 after calculating the isotope pattern.
|
| 1524 |
+
|
| 1525 |
+
:param int npeaks: The number of peaks to keep if *dropmethod* is 'npeaks'. See *dropmethod* for more details.
|
| 1526 |
+
|
| 1527 |
+
:param float threshold: The threshold value determining whether or not to drop a peak. Only has an effect if
|
| 1528 |
+
*dropmethod* is not ``None``. See *dropmethod* for more details.
|
| 1529 |
+
|
| 1530 |
+
:param bool verbose: Verbose output. Mostly useful when calculating for large molecules or while debugging.
|
| 1531 |
+
|
| 1532 |
+
"""
|
| 1533 |
+
# todo implement apply_threshold method for trimming resulting spectrum
|
| 1534 |
+
self.ipmethod = ipmethod
|
| 1535 |
+
self._spectrum_raw = None # spectrum object holder
|
| 1536 |
+
self._raw = None # raw isotope pattern
|
| 1537 |
+
self.bar_isotope_pattern = [[], []]
|
| 1538 |
+
self.criticalerror = criticalerror
|
| 1539 |
+
self.decpl = decpl
|
| 1540 |
+
self.dropmethod = dropmethod
|
| 1541 |
+
self.emptyspec = emptyspec
|
| 1542 |
+
self.consolidate = consolidate
|
| 1543 |
+
self.groupmethod = groupmethod
|
| 1544 |
+
self.keepall = keepall
|
| 1545 |
+
self.npeaks = npeaks
|
| 1546 |
+
self.resolution = resolution
|
| 1547 |
+
self.threshold = threshold
|
| 1548 |
+
self.save = save # todo reimplement and detail in docstring
|
| 1549 |
+
|
| 1550 |
+
if precalculated is not None: # if precalculated values were provided, pull and set to prevent recalculation
|
| 1551 |
+
self._comp = precalculated['composition']
|
| 1552 |
+
self._spectrum_raw = precalculated['spectrum']
|
| 1553 |
+
self.bar_isotope_pattern = precalculated['barip']
|
| 1554 |
+
self._raw = precalculated['rawip']
|
| 1555 |
+
self._gausip = precalculated['gausip']
|
| 1556 |
+
|
| 1557 |
+
Molecule.__init__(
|
| 1558 |
+
self,
|
| 1559 |
+
string,
|
| 1560 |
+
charge,
|
| 1561 |
+
verbose=verbose,
|
| 1562 |
+
)
|
| 1563 |
+
|
| 1564 |
+
if save is True:
|
| 1565 |
+
self.save_to_jcamp()
|
| 1566 |
+
|
| 1567 |
+
def __reduce__(self):
|
| 1568 |
+
return (
|
| 1569 |
+
self.__class__,
|
| 1570 |
+
self.__getinitargs__(),
|
| 1571 |
+
)
|
| 1572 |
+
|
| 1573 |
+
def __getinitargs__(self):
|
| 1574 |
+
return (
|
| 1575 |
+
self.composition,
|
| 1576 |
+
self.charge,
|
| 1577 |
+
self.consolidate,
|
| 1578 |
+
self.criticalerror,
|
| 1579 |
+
self.decpl,
|
| 1580 |
+
self.dropmethod,
|
| 1581 |
+
self.emptyspec,
|
| 1582 |
+
self.groupmethod,
|
| 1583 |
+
self.ipmethod,
|
| 1584 |
+
self.keepall,
|
| 1585 |
+
self.npeaks,
|
| 1586 |
+
self.resolution,
|
| 1587 |
+
self.threshold,
|
| 1588 |
+
self.save,
|
| 1589 |
+
self.verbose,
|
| 1590 |
+
{ # precalculated values
|
| 1591 |
+
'composition': self.composition,
|
| 1592 |
+
'spectrum': self.spectrum_raw,
|
| 1593 |
+
'rawip': self.raw_isotope_pattern,
|
| 1594 |
+
'barip': self.bar_isotope_pattern,
|
| 1595 |
+
'gausip': self.gaussian_isotope_pattern if self._gausip is not None else None,
|
| 1596 |
+
},
|
| 1597 |
+
)
|
| 1598 |
+
|
| 1599 |
+
@property
|
| 1600 |
+
def ipmethod(self):
|
| 1601 |
+
return self._ipmethod
|
| 1602 |
+
|
| 1603 |
+
@ipmethod.setter
|
| 1604 |
+
def ipmethod(self, value):
|
| 1605 |
+
if value not in VALID_IPMETHODS:
|
| 1606 |
+
raise ValueError(f'The isotope pattern generation method {value} is not valid. ipmethod must be one '
|
| 1607 |
+
f'of: {", ".join(VALID_IPMETHODS)}')
|
| 1608 |
+
self._ipmethod = value
|
| 1609 |
+
|
| 1610 |
+
@property
|
| 1611 |
+
def dropmethod(self):
|
| 1612 |
+
return self._dropmethod
|
| 1613 |
+
|
| 1614 |
+
@dropmethod.setter
|
| 1615 |
+
def dropmethod(self, value):
|
| 1616 |
+
if value not in VALID_DROPMETHODS:
|
| 1617 |
+
raise ValueError(f'The intensity dropping method {value} is not valid. dropmethod must be one '
|
| 1618 |
+
f'of: {", ".join(VALID_DROPMETHODS)}')
|
| 1619 |
+
self._dropmethod = value
|
| 1620 |
+
|
| 1621 |
+
@property
|
| 1622 |
+
def estimated_exact_mass(self):
|
| 1623 |
+
"""determines the precise exact mass from the bar isotope pattern"""
|
| 1624 |
+
ind = self.bar_isotope_pattern[1].index(
|
| 1625 |
+
max(self.bar_isotope_pattern[1])
|
| 1626 |
+
)
|
| 1627 |
+
return self.bar_isotope_pattern[0][ind]
|
| 1628 |
+
|
| 1629 |
+
@property
|
| 1630 |
+
def em(self):
|
| 1631 |
+
"""Legacy attribute access: estimated exact mass"""
|
| 1632 |
+
return self.estimated_exact_mass
|
| 1633 |
+
|
| 1634 |
+
@property
|
| 1635 |
+
def molecular_weight_estimated(self):
|
| 1636 |
+
"""The molecular weight of the molecule estimated by the isotope pattern"""
|
| 1637 |
+
return pattern_molecular_weight(
|
| 1638 |
+
*self.raw_isotope_pattern,
|
| 1639 |
+
charge=self.charge,
|
| 1640 |
+
)
|
| 1641 |
+
|
| 1642 |
+
@property
|
| 1643 |
+
def pmw(self):
|
| 1644 |
+
"""Legacy retrieval of pattern molecular weight"""
|
| 1645 |
+
return self.molecular_weight_estimated
|
| 1646 |
+
|
| 1647 |
+
@property
|
| 1648 |
+
def error(self):
|
| 1649 |
+
"""Error of the generated isotope pattern"""
|
| 1650 |
+
return molecular_weight_error(
|
| 1651 |
+
calculated=self.molecular_weight_estimated,
|
| 1652 |
+
expected=self.molecular_weight,
|
| 1653 |
+
)
|
| 1654 |
+
|
| 1655 |
+
@property
|
| 1656 |
+
def sigma(self):
|
| 1657 |
+
"""Standard deviation of the isotope pattern"""
|
| 1658 |
+
return standard_deviation(self.fwhm)
|
| 1659 |
+
|
| 1660 |
+
@property
|
| 1661 |
+
def nominal_mass(self):
|
| 1662 |
+
"""the nominal mass of the molecule"""
|
| 1663 |
+
return int(round(self.em))
|
| 1664 |
+
|
| 1665 |
+
@property
|
| 1666 |
+
def fwhm(self):
|
| 1667 |
+
try: # try to return from estimated, unless uncalculated, use monoisotopic
|
| 1668 |
+
return self.estimated_exact_mass / self.resolution
|
| 1669 |
+
except (IndexError, ValueError):
|
| 1670 |
+
return self.monoisotopic_mass / self.resolution
|
| 1671 |
+
|
| 1672 |
+
@property
|
| 1673 |
+
def barip(self):
|
| 1674 |
+
"""Legacy attribute access"""
|
| 1675 |
+
return self.bar_isotope_pattern
|
| 1676 |
+
|
| 1677 |
+
@property
|
| 1678 |
+
def raw_isotope_pattern(self):
|
| 1679 |
+
if self._raw is None:
|
| 1680 |
+
self._raw = self.spectrum_raw.trim()
|
| 1681 |
+
return self._raw
|
| 1682 |
+
|
| 1683 |
+
@property
|
| 1684 |
+
def rawip(self):
|
| 1685 |
+
"""Legacy attribute access"""
|
| 1686 |
+
return self.raw_isotope_pattern
|
| 1687 |
+
|
| 1688 |
+
@property
|
| 1689 |
+
def spectrum_raw(self):
|
| 1690 |
+
return self._spectrum_raw
|
| 1691 |
+
|
| 1692 |
+
@property
|
| 1693 |
+
def gaussian_isotope_pattern(self):
|
| 1694 |
+
if self._gausip is None: # if it hasn't been calculated, generate
|
| 1695 |
+
self._gausip = gaussian_isotope_pattern(
|
| 1696 |
+
self.bar_isotope_pattern,
|
| 1697 |
+
self.fwhm
|
| 1698 |
+
)
|
| 1699 |
+
return self._gausip
|
| 1700 |
+
|
| 1701 |
+
@property
|
| 1702 |
+
def gausip(self):
|
| 1703 |
+
"""Legacy retrieval"""
|
| 1704 |
+
return self.gaussian_isotope_pattern
|
| 1705 |
+
|
| 1706 |
+
@property
|
| 1707 |
+
def composition(self):
|
| 1708 |
+
return self._comp
|
| 1709 |
+
|
| 1710 |
+
@composition.setter
|
| 1711 |
+
def composition(self, dct):
|
| 1712 |
+
if type(dct) != dict:
|
| 1713 |
+
raise TypeError('The composition must be a dictionary')
|
| 1714 |
+
if dct == self.composition: # do nothing if the composition dictionary is the same as current
|
| 1715 |
+
return
|
| 1716 |
+
dct = copy.copy(dct)
|
| 1717 |
+
dct = abbreviations(dct) # check for and convert abbreviations
|
| 1718 |
+
if 'charge' in dct: # if charge was specified in the formula
|
| 1719 |
+
self.charge, self.sign = interpret_charge(dct['charge'])
|
| 1720 |
+
del dct['charge']
|
| 1721 |
+
check_in_mass_dict(dct) # check in mass dictionary
|
| 1722 |
+
self._comp = dct # set local dictionary
|
| 1723 |
+
self._calculate_ips() # calculate isotope patterns
|
| 1724 |
+
# todo save to pickle
|
| 1725 |
+
|
| 1726 |
+
@property
|
| 1727 |
+
def isotope_pattern_standard_deviation(self):
|
| 1728 |
+
"""
|
| 1729 |
+
Cacluates the standard deviation of the isotope pattern of the supplied composition
|
| 1730 |
+
this calculation is based on Rockwood and Van Orden 1996 doi: 10.1021/ac951158i
|
| 1731 |
+
"""
|
| 1732 |
+
return np.sqrt(
|
| 1733 |
+
sum([
|
| 1734 |
+
intensity * (mz - self.pmw) ** 2 # weighted distance from the estimated molecular weight
|
| 1735 |
+
for mz, intensity in zip(*self.raw_isotope_pattern)
|
| 1736 |
+
])
|
| 1737 |
+
)
|
| 1738 |
+
|
| 1739 |
+
@property
|
| 1740 |
+
def bounds(self):
|
| 1741 |
+
"""Convenient attribute access to default bounds. Call calculate_bounds for additional options. """
|
| 1742 |
+
return self.calculate_bounds()
|
| 1743 |
+
|
| 1744 |
+
@property
|
| 1745 |
+
def per_peak_bounds(self):
|
| 1746 |
+
"""Convenient attribute access to per-peak bounds. Call calculate_bounds for additional options. """
|
| 1747 |
+
return self.calculate_bounds(perpeak=True)
|
| 1748 |
+
|
| 1749 |
+
def calculate_bounds(
|
| 1750 |
+
self,
|
| 1751 |
+
conf: float = 0.95,
|
| 1752 |
+
perpeak: bool = False,
|
| 1753 |
+
threshold: float = 0.01
|
| 1754 |
+
):
|
| 1755 |
+
"""
|
| 1756 |
+
Calculates the *m/z* bounds of the isotope pattern of the molecule object based
|
| 1757 |
+
on a confidence interval and the *m/z* values of the bar isotope pattern.
|
| 1758 |
+
This can be used to automatically determine the integration bounds required to
|
| 1759 |
+
contain XX% of the counts associated with that molecule in a mass spectrum.
|
| 1760 |
+
|
| 1761 |
+
:param conf: The confidence interval to use for calculating the bounds.
|
| 1762 |
+
e.g. *0.95* corresponds to a 95% confidence interval.
|
| 1763 |
+
:param perpeak: Whether or not to return the bounds required to integrate each
|
| 1764 |
+
peak of the isotope pattern individually.
|
| 1765 |
+
This can be useful in a very noisy mass spectrum to avoid
|
| 1766 |
+
baseline noise within the integration interval.
|
| 1767 |
+
:param threshold: The threshold used to determine whether a peak should be
|
| 1768 |
+
included in the bounds.
|
| 1769 |
+
:return: bounds.
|
| 1770 |
+
If *perpeak* is False, this will return a two item list
|
| 1771 |
+
corresponding to the start and end *m/z* bounds.
|
| 1772 |
+
If *perpeak* is True, returns a dictionary of bounds with
|
| 1773 |
+
the key format of
|
| 1774 |
+
``dict[parent m/z value]['bounds'] = [start m/z, end m/z]``
|
| 1775 |
+
|
| 1776 |
+
**Examples**
|
| 1777 |
+
|
| 1778 |
+
To determine the integration bounds of C61H51IP3Pd:
|
| 1779 |
+
|
| 1780 |
+
::
|
| 1781 |
+
|
| 1782 |
+
>>> mol = IPMolecule('C61H51IP3Pd')
|
| 1783 |
+
>>> mol.calculate_bounds(0.95)
|
| 1784 |
+
[1104.9458115053008, 1116.3249999321531]
|
| 1785 |
+
|
| 1786 |
+
>>> mol.calculate_bounds(0.99)
|
| 1787 |
+
[1104.8877964620444, 1116.3830149754094]
|
| 1788 |
+
|
| 1789 |
+
>>> mol.calculate_bounds(0.95, True)
|
| 1790 |
+
{'1105.1304418': {'bounds': (1104.9458115053008, 1105.3150720946992)},
|
| 1791 |
+
'1106.13382235': {'bounds': (1105.9491920547823, 1106.3184526441808)},
|
| 1792 |
+
'1107.12903188': {'bounds': (1106.9444015896975, 1107.3136621790959)},
|
| 1793 |
+
'1108.13051519': {'bounds': (1107.9458848935217, 1108.3151454829201)},
|
| 1794 |
+
'1109.13037767': {'bounds': (1108.9457473736579, 1109.3150079630564)},
|
| 1795 |
+
'1110.13288962': {'bounds': (1109.9482593265234, 1110.3175199159218)},
|
| 1796 |
+
'1111.13024042': {'bounds': (1110.9456101206658, 1111.3148707100643)},
|
| 1797 |
+
'1112.13263766': {'bounds': (1111.9480073654438, 1112.3172679548422)},
|
| 1798 |
+
'1113.13193341': {'bounds': (1112.9473031156144, 1113.3165637050129)},
|
| 1799 |
+
'1114.13415503': {'bounds': (1113.9495247326277, 1114.3187853220261)},
|
| 1800 |
+
'1115.13715205': {'bounds': (1114.9525217596001, 1115.3217823489986)},
|
| 1801 |
+
'1116.14036964': {'bounds': (1115.9557393427547, 1116.3249999321531)}}
|
| 1802 |
+
|
| 1803 |
+
"""
|
| 1804 |
+
if self.verbose is True:
|
| 1805 |
+
sys.stdout.write('Calculating bounds from simulated gaussian isotope pattern')
|
| 1806 |
+
threshold = threshold * max(self.bar_isotope_pattern[1])
|
| 1807 |
+
tempip = [[], []]
|
| 1808 |
+
for ind, inten in enumerate(self.bar_isotope_pattern[1]): # checks for intensities above threshold
|
| 1809 |
+
if inten >= threshold:
|
| 1810 |
+
tempip[0].append(self.bar_isotope_pattern[0][ind])
|
| 1811 |
+
tempip[1].append(self.bar_isotope_pattern[1][ind])
|
| 1812 |
+
if perpeak is True: # if per-peak bounds are called for
|
| 1813 |
+
out = {}
|
| 1814 |
+
for mz in tempip[0]:
|
| 1815 |
+
out[str(mz)] = {}
|
| 1816 |
+
out[str(mz)]['bounds'] = stats.norm.interval(conf, mz, scale=self.sigma)
|
| 1817 |
+
else: # a general range that covers the entire isotope pattern
|
| 1818 |
+
out = [stats.norm.interval(conf, tempip[0][0], scale=self.sigma)[0],
|
| 1819 |
+
stats.norm.interval(conf, tempip[0][-1], scale=self.sigma)[1]]
|
| 1820 |
+
if self.verbose is True:
|
| 1821 |
+
if perpeak is False:
|
| 1822 |
+
sys.stdout.write(': %.3f-%.3f' % (out[0], out[1]))
|
| 1823 |
+
sys.stdout.write(' DONE\n')
|
| 1824 |
+
return out
|
| 1825 |
+
|
| 1826 |
+
def _calculate_ips(self):
|
| 1827 |
+
"""Call to calculate isotope patterns based on the specified parameters"""
|
| 1828 |
+
# generates the raw isotope pattern (charge of 1)
|
| 1829 |
+
if self.ipmethod == 'combinatorics':
|
| 1830 |
+
calculator = isotope_pattern_combinatoric
|
| 1831 |
+
elif self.ipmethod == 'multiplicative':
|
| 1832 |
+
calculator = isotope_pattern_multiplicative
|
| 1833 |
+
elif self.ipmethod == 'hybrid':
|
| 1834 |
+
calculator = isotope_pattern_hybrid
|
| 1835 |
+
# elif self.ipmethod == 'cuda':
|
| 1836 |
+
# calculator = isotope_pattern_cuda
|
| 1837 |
+
elif self.ipmethod == 'isospec':
|
| 1838 |
+
calculator = isotope_pattern_isospec
|
| 1839 |
+
else:
|
| 1840 |
+
raise ValueError(f'The isotope pattern method {self.ipmethod} is not valid')
|
| 1841 |
+
|
| 1842 |
+
self._spectrum_raw = calculator(
|
| 1843 |
+
self.composition,
|
| 1844 |
+
decpl=self.decpl,
|
| 1845 |
+
verbose=self.verbose,
|
| 1846 |
+
dropmethod=self.dropmethod,
|
| 1847 |
+
threshold=self.threshold,
|
| 1848 |
+
npeaks=self.npeaks,
|
| 1849 |
+
consolidate=self.consolidate,
|
| 1850 |
+
fwhm=self.fwhm,
|
| 1851 |
+
)
|
| 1852 |
+
|
| 1853 |
+
# apply charge
|
| 1854 |
+
self.spectrum_raw.charge = self.charge
|
| 1855 |
+
|
| 1856 |
+
# generate bar isotope pattern based on the raw pattern
|
| 1857 |
+
self.bar_isotope_pattern = bar_isotope_pattern(
|
| 1858 |
+
self.raw_isotope_pattern,
|
| 1859 |
+
self.fwhm
|
| 1860 |
+
)
|
| 1861 |
+
|
| 1862 |
+
def compare(self, exp):
|
| 1863 |
+
"""
|
| 1864 |
+
Compares a provided mass spectrum (experimental) to the simulated gaussian
|
| 1865 |
+
isotope pattern. Returns a standard error of the regression as an assessment
|
| 1866 |
+
of the goodness of fit.
|
| 1867 |
+
|
| 1868 |
+
**Parameters**
|
| 1869 |
+
|
| 1870 |
+
exp: *list*
|
| 1871 |
+
The experimentally acquired mass spectra provided as a paired list of lists
|
| 1872 |
+
``[[m/z values],[intensity values]]``
|
| 1873 |
+
|
| 1874 |
+
|
| 1875 |
+
**Returns**
|
| 1876 |
+
|
| 1877 |
+
Standard error of the regression: *float*
|
| 1878 |
+
A measure of the average distance between the experimental and predicted
|
| 1879 |
+
values. Lower is better, although this is a qualitative assessment.
|
| 1880 |
+
|
| 1881 |
+
"""
|
| 1882 |
+
|
| 1883 |
+
def sumsquare(lst):
|
| 1884 |
+
"""calculates the sum of squares"""
|
| 1885 |
+
ss = 0
|
| 1886 |
+
for val in lst:
|
| 1887 |
+
ss += val ** 2
|
| 1888 |
+
return ss
|
| 1889 |
+
# TODO fix this method (worthwhile?)
|
| 1890 |
+
# - 2015-09-15 06 gives a bounds error
|
| 1891 |
+
yvals = []
|
| 1892 |
+
res = []
|
| 1893 |
+
maxy = float(max(exp[1]))
|
| 1894 |
+
if maxy == 0.:
|
| 1895 |
+
return 'could not calculate'
|
| 1896 |
+
for ind, val in enumerate(exp[1]): # normalize y values
|
| 1897 |
+
yvals.append(float(val) / maxy * 100.)
|
| 1898 |
+
# avgy = sum(exp[1])/len(exp[1])
|
| 1899 |
+
for ind, mz in enumerate(exp[0]):
|
| 1900 |
+
if min(self.gausip[0]) < mz < max(self.gausip[0]): # if within isotope pattern
|
| 1901 |
+
nspind = self.spectrum_raw.index(mz) # calculate index
|
| 1902 |
+
if self.spectrum_raw.y[nspind] is not None: # if the predicted intensity is not None
|
| 1903 |
+
# difference between observed and predicted (residuals)
|
| 1904 |
+
res.append(yvals[ind] - self.spectrum_raw.y[nspind])
|
| 1905 |
+
# tot.append(self.spec.y[nspind]-avgy) # difference between predicted and mean
|
| 1906 |
+
# rsqrd = 1-(sumsquare(res)/sumsquare(tot)) # r-squared value (apparently not applicable to non-linear fits)
|
| 1907 |
+
return np.sqrt(sumsquare(res) / len(res))
|
| 1908 |
+
|
| 1909 |
+
def compare_exact_mass(self, mass, use='est'):
|
| 1910 |
+
"""
|
| 1911 |
+
Compares the provided mass to the exact mass of the calculated molecule.
|
| 1912 |
+
|
| 1913 |
+
**Parameters**
|
| 1914 |
+
|
| 1915 |
+
mass: *float*
|
| 1916 |
+
experimental mass to compare
|
| 1917 |
+
|
| 1918 |
+
use: est or mi (optional)
|
| 1919 |
+
Whether to compare the estimated exact mass or the monoisotopic
|
| 1920 |
+
mass to the provided value. Default: est
|
| 1921 |
+
|
| 1922 |
+
**Returns**
|
| 1923 |
+
|
| 1924 |
+
relative error: *float*
|
| 1925 |
+
The relative error of the provided mass to the exact mass
|
| 1926 |
+
"""
|
| 1927 |
+
if use == 'est':
|
| 1928 |
+
delta = mass - self.em
|
| 1929 |
+
return delta / self.em * 10 ** 6
|
| 1930 |
+
elif use == 'mi':
|
| 1931 |
+
delta = mass - self.mimass
|
| 1932 |
+
return delta / self.mimass * 10 ** 6
|
| 1933 |
+
|
| 1934 |
+
def load_from_pickle(self, customfile=None):
|
| 1935 |
+
"""loads data from pickle"""
|
| 1936 |
+
raise NotImplementedError('This functionality has been temporarily disabled due to significant changes in the '
|
| 1937 |
+
'class. ')
|
| 1938 |
+
# TODO specify hierachy and pull if better method than specified
|
| 1939 |
+
if customfile is None: # if no directory was specified, use current working directory
|
| 1940 |
+
customfile = os.path.join(
|
| 1941 |
+
os.getcwd(),
|
| 1942 |
+
'molecules',
|
| 1943 |
+
self.molecular_formula(self.comp) + '.mol',
|
| 1944 |
+
)
|
| 1945 |
+
if os.path.isfile(customfile) is True:
|
| 1946 |
+
if self.ipmethod.lower() == 'multiplicative':
|
| 1947 |
+
key = 'multiplicative'
|
| 1948 |
+
elif self.ipmethod.lower() == 'combinatorics':
|
| 1949 |
+
key = 'combinatorics'
|
| 1950 |
+
if self.dropmethod is not None:
|
| 1951 |
+
key += ' %s' % self.dropmethod
|
| 1952 |
+
subkey = self.decpl # decimal places
|
| 1953 |
+
with open(customfile, 'rb') as targetfile:
|
| 1954 |
+
incoming = pickle.load(targetfile)
|
| 1955 |
+
if key in incoming and subkey in incoming[key]:
|
| 1956 |
+
items = incoming[key][subkey]
|
| 1957 |
+
strcharge = '%s%d' % (self.sign, self.charge)
|
| 1958 |
+
if items['charge'] == strcharge: # if the charge combination matches
|
| 1959 |
+
print('Loading data from saved file %s' % customfile)
|
| 1960 |
+
self.bar_isotope_pattern = items['bar isotope pattern']
|
| 1961 |
+
self.raw_isotope_pattern = items['raw isotope pattern']
|
| 1962 |
+
self.gausip = items['gaussian isotope pattern']
|
| 1963 |
+
self.mw = items['mw']
|
| 1964 |
+
self.mimass = items['monoisotopic mass']
|
| 1965 |
+
self.em = items['estimated exact mass']
|
| 1966 |
+
self.pcomp = items['percent composition']
|
| 1967 |
+
self.error = items['error']
|
| 1968 |
+
self.fwhm = items['full width at half max']
|
| 1969 |
+
self.sigma = items['standard deviation']
|
| 1970 |
+
self.sf = self.molecular_formula(self.comp)
|
| 1971 |
+
return True
|
| 1972 |
+
return False # if the exact match was not found, False
|
| 1973 |
+
|
| 1974 |
+
def print_details(self):
|
| 1975 |
+
"""prints the details of the generated molecule"""
|
| 1976 |
+
sys.stdout.write(f'{self}\n')
|
| 1977 |
+
sys.stdout.write(f'formula: {self.molecular_formula}\n')
|
| 1978 |
+
sys.stdout.write(f'molecular weight: {round(self.molecular_weight, self.decpl)}\n')
|
| 1979 |
+
sys.stdout.write(f'monoisotopic mass: {round(self.monoisotopic_mass, self.decpl)}\n')
|
| 1980 |
+
sys.stdout.write(f'estimated exact mass: {round(self.estimated_exact_mass, self.decpl)}\n')
|
| 1981 |
+
sys.stdout.write(f'error: {self.error:.3}\n')
|
| 1982 |
+
if abs(self.error) > self.criticalerror:
|
| 1983 |
+
sys.stdout.write(f'WARNING: Error is greater than {self.criticalerror}!\n')
|
| 1984 |
+
sys.stdout.write('\n')
|
| 1985 |
+
self.print_percent_composition()
|
| 1986 |
+
|
| 1987 |
+
def plot_bar_pattern(self):
|
| 1988 |
+
"""plots and shows the isotope bar pattern"""
|
| 1989 |
+
fwhm = self.em / self.resolution
|
| 1990 |
+
pl.bar(self.bar_isotope_pattern[0], self.bar_isotope_pattern[1], width=fwhm, align='center')
|
| 1991 |
+
pl.xlabel('m/z', style='italic')
|
| 1992 |
+
pl.ylabel('normalized intensity')
|
| 1993 |
+
pl.ticklabel_format(useOffset=False)
|
| 1994 |
+
pl.show()
|
| 1995 |
+
|
| 1996 |
+
def plot_gaussian_pattern(self, exp=None):
|
| 1997 |
+
"""plots and shows the simulated gaussian isotope pattern"""
|
| 1998 |
+
pl.plot(*self.gaussian_isotope_pattern, linewidth=1)
|
| 1999 |
+
if exp is not None: # plots experimental if supplied
|
| 2000 |
+
y = []
|
| 2001 |
+
maxy = max(exp[1])
|
| 2002 |
+
for val in exp[1]: # normalize
|
| 2003 |
+
y.append(val / maxy * 100)
|
| 2004 |
+
comp = self.compare(exp)
|
| 2005 |
+
pl.plot(exp[0], exp[1])
|
| 2006 |
+
pl.text(max(exp[0]), 95, 'SER: ' + str(comp))
|
| 2007 |
+
# pl.fill_between(x,self.gausip[1],exp[1],where= exp[1] =< self.gausip[1],interpolate=True, facecolor='red')
|
| 2008 |
+
pl.fill(self.gausip[0], self.gausip[1], alpha=0.25) # ,facecolor='blue')
|
| 2009 |
+
pl.xlabel('m/z', style='italic')
|
| 2010 |
+
pl.ylabel('normalized intensity')
|
| 2011 |
+
pl.ticklabel_format(useOffset=False)
|
| 2012 |
+
pl.show()
|
| 2013 |
+
|
| 2014 |
+
def plot_raw_pattern(self):
|
| 2015 |
+
"""plots and shows the raw isotope pattern (with mass defects preserved)"""
|
| 2016 |
+
pl.bar(self.raw_isotope_pattern[0], self.raw_isotope_pattern[1], width=self.fwhm)
|
| 2017 |
+
pl.xlabel('m/z', style='italic')
|
| 2018 |
+
pl.ylabel('normalized intensity')
|
| 2019 |
+
pl.ticklabel_format(useOffset=False)
|
| 2020 |
+
pl.show()
|
| 2021 |
+
|
| 2022 |
+
def save_to_jcamp(self, name=None):
|
| 2023 |
+
"""
|
| 2024 |
+
Saves the bar isotope pattern to JCAMP-DX file format
|
| 2025 |
+
Output type roughly based on the output from ChemCalc.org
|
| 2026 |
+
see http://www.jcamp-dx.org/protocols.html for details on the JCAMP-DX specifications.
|
| 2027 |
+
|
| 2028 |
+
:param name: optional name for the output file (default is {molecular formula}.jdx)
|
| 2029 |
+
"""
|
| 2030 |
+
if os.path.isdir(os.path.join(os.getcwd(), 'molecules')) is False:
|
| 2031 |
+
os.makedirs(os.path.join(os.getcwd(), 'molecules'))
|
| 2032 |
+
if name is None: # if no name supplied, auto generate
|
| 2033 |
+
name = self.molecular_formula
|
| 2034 |
+
name += '.jdx'
|
| 2035 |
+
elif name.lower().endswith('.jdx') is False:
|
| 2036 |
+
name += '.jdx'
|
| 2037 |
+
|
| 2038 |
+
if self.verbose is True:
|
| 2039 |
+
sys.stdout.write(f'Saving {name} to {os.path.join(os.getcwd(), "molecules")}')
|
| 2040 |
+
sys.stdout.flush()
|
| 2041 |
+
|
| 2042 |
+
header = [ # comment lines to put before data
|
| 2043 |
+
# header items
|
| 2044 |
+
f'TITLE= {self.molecular_formula}',
|
| 2045 |
+
'JCAMP-DX= 5.01',
|
| 2046 |
+
'DATA TYPE= MASS SPECTRUM',
|
| 2047 |
+
'DATA CLASS= PEAK TABLE',
|
| 2048 |
+
f'ORIGIN= Calculated spectrum from PythoMS {self.__class__} class https://github.com/larsyunker/PythoMS',
|
| 2049 |
+
f'OWNER= {os.getlogin()}',
|
| 2050 |
+
f'SPECTROMETER/DATA SYSTEM= {self.__class__} class {self.ipmethod} method',
|
| 2051 |
+
f'CREATION DATE= {datetime.now().astimezone()}',
|
| 2052 |
+
'XUNITS= M/Z',
|
| 2053 |
+
'YUNITS= RELATIVE ABUNDANCE',
|
| 2054 |
+
f'NPOINTS= {len(self.bar_isotope_pattern[0])}',
|
| 2055 |
+
f'FIRSTX= {self.bar_isotope_pattern[0][0]}',
|
| 2056 |
+
f'LASTX= {self.bar_isotope_pattern[0][1]}',
|
| 2057 |
+
|
| 2058 |
+
# user defined labels
|
| 2059 |
+
f'$Molecular weight= {self.molecular_weight}',
|
| 2060 |
+
f'$Resolution= {self.res}',
|
| 2061 |
+
f'$Threshold= {self.threshold if self.threshold is not None else ""}',
|
| 2062 |
+
f'$Error= {self.error:.2}',
|
| 2063 |
+
f'$Nominal mass = {self.nominal_mass}',
|
| 2064 |
+
f'$Monoisotopic mass= {self.monoisotopic_mass}',
|
| 2065 |
+
f'$Estimated exact mass= {self.estimated_exact_mass}',
|
| 2066 |
+
]
|
| 2067 |
+
with open(os.path.join(os.getcwd(), "molecules", name), 'wt') as outfile:
|
| 2068 |
+
for line in header: # write header lines
|
| 2069 |
+
if len(line) != 0:
|
| 2070 |
+
outfile.write(f'##{line}\n')
|
| 2071 |
+
outfile.write('##PEAK TABLE= (XY..XY)\n')
|
| 2072 |
+
for mz, intensity in zip(*self.bar_isotope_pattern): # write data lines
|
| 2073 |
+
outfile.write(f'{mz}, {intensity}\n')
|
| 2074 |
+
outfile.write('##END=\n')
|
| 2075 |
+
|
| 2076 |
+
def save_to_pickle(self, name=None):
|
| 2077 |
+
"""
|
| 2078 |
+
Saves the molecule's properties to pickle
|
| 2079 |
+
"""
|
| 2080 |
+
if os.path.isdir(os.path.join(os.getcwd(), 'molecules')) is False:
|
| 2081 |
+
os.makedirs(os.path.join(os.getcwd(), 'molecules'))
|
| 2082 |
+
if name is None: # if no name supplied, auto generate
|
| 2083 |
+
name = self.molecular_formula
|
| 2084 |
+
name += '.mol'
|
| 2085 |
+
elif name.lower().endswith('.mol') is False:
|
| 2086 |
+
name += '.mol'
|
| 2087 |
+
|
| 2088 |
+
if self.verbose is True:
|
| 2089 |
+
sys.stdout.write(f'Saving {name} to {os.path.join(os.getcwd(), "molecules")}')
|
| 2090 |
+
sys.stdout.flush()
|
| 2091 |
+
|
| 2092 |
+
with open(os.path.join(os.getcwd(), "molecules", name), 'wb') as outfile:
|
| 2093 |
+
pickle.dump(
|
| 2094 |
+
self,
|
| 2095 |
+
outfile
|
| 2096 |
+
)
|
| 2097 |
+
|
| 2098 |
+
# todo differentiate between generation methods in the output files
|
| 2099 |
+
|
| 2100 |
+
|
| 2101 |
+
if __name__ == '__main__': # for testing and troubleshooting
|
| 2102 |
+
# st.printstart()
|
| 2103 |
+
mol = IPMolecule(
|
| 2104 |
+
'L2PdAr+I',
|
| 2105 |
+
# charge= 2, # specify charge (if not specified in formula)
|
| 2106 |
+
# res=1050000, # specify spectrometer resolution (default 5000)
|
| 2107 |
+
verbose=True,
|
| 2108 |
+
# decpl=10,
|
| 2109 |
+
# dropmethod='threshold',
|
| 2110 |
+
# threshold=0.00001,
|
| 2111 |
+
# ipmethod='hybrid',
|
| 2112 |
+
ipmethod='combinatorics',
|
| 2113 |
+
# keepall=True,
|
| 2114 |
+
)
|
| 2115 |
+
# mol.print_details()
|
| 2116 |
+
# st.printelapsed()
|
| 2117 |
+
# st.printprofiles()
|
lib/pythoms/progress.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Progress(object):
|
| 5 |
+
def __init__(self,
|
| 6 |
+
first: int = 1, # the initial iteration
|
| 7 |
+
last: int = 10, # the final iteration
|
| 8 |
+
string: str = 'Processing iteration', # the string prefix that is returned
|
| 9 |
+
fraction: bool = True, # whether to output the fraction of things completed
|
| 10 |
+
rng: bool = False, # whether to output the range that the iterations span
|
| 11 |
+
percent: bool = True, # whether to show percent completion
|
| 12 |
+
hash: bool = False, # whether to have a hash bar for progress
|
| 13 |
+
hashnum: int = 20, # width of the hash progress bar
|
| 14 |
+
endmsg: str = 'DONE', # end message
|
| 15 |
+
writeevery: int = 1, # write output every n calls
|
| 16 |
+
):
|
| 17 |
+
"""
|
| 18 |
+
A progress output object for use when performing a large number of
|
| 19 |
+
repititions of the same process and informing the user of progress
|
| 20 |
+
is desired.
|
| 21 |
+
|
| 22 |
+
:param first: The first iteration of the process.
|
| 23 |
+
:param last: The last iteration of the process.
|
| 24 |
+
:param string: The string prefix that is written (this is usually information about
|
| 25 |
+
the process.
|
| 26 |
+
:param fraction: Whether the fractional progress should be written in the printed string. e.g. 1/10.
|
| 27 |
+
:param rng: Whether the iteration range should be written in the printed string.
|
| 28 |
+
e.g. (1-10)
|
| 29 |
+
:param percent: Whether the percent progress should be written in the printed string.
|
| 30 |
+
e.g. 10.0%
|
| 31 |
+
:param hash: Whether a hash-type progress bar should be written in the printed
|
| 32 |
+
string. e.g. |##### | Default: False
|
| 33 |
+
:param hashnum: If *hashes* is True, this is the width of the hash-type progress bar.
|
| 34 |
+
:param endmsg: The message that is printed when the `fin()` method is called.
|
| 35 |
+
:param writeevery: Write an output every n calls. This can be used if an iteration is
|
| 36 |
+
rapid and printing every output is not particularly useful.
|
| 37 |
+
|
| 38 |
+
**Examples**
|
| 39 |
+
|
| 40 |
+
::
|
| 41 |
+
|
| 42 |
+
>>> prog = Progress()
|
| 43 |
+
>>> prog.write(1)
|
| 44 |
+
Processing iteration #1/10 0.0%
|
| 45 |
+
|
| 46 |
+
>>> prog.write(7)
|
| 47 |
+
Processing iteration #7/10 66.7%
|
| 48 |
+
|
| 49 |
+
>>> for i in range(1,11):
|
| 50 |
+
prog.write(i)
|
| 51 |
+
prog.fin()
|
| 52 |
+
Processing iteration #10/10 100.0% DONE
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
"""
|
| 56 |
+
self.first = first
|
| 57 |
+
self.last = last
|
| 58 |
+
self.string = string
|
| 59 |
+
self.fraction = fraction
|
| 60 |
+
self.rng = rng
|
| 61 |
+
self.percent = percent
|
| 62 |
+
self.hash = hash
|
| 63 |
+
self.hashnum = hashnum
|
| 64 |
+
self.endmsg = endmsg
|
| 65 |
+
self.writeevery = writeevery
|
| 66 |
+
self.wr = sys.stdout.write
|
| 67 |
+
self.fl = sys.stdout.flush
|
| 68 |
+
self.count = 0
|
| 69 |
+
self.strlen = 0
|
| 70 |
+
self.current = 0 # current state
|
| 71 |
+
self.spinner = ['|', '/', '-', '\\']
|
| 72 |
+
|
| 73 |
+
def __str__(self):
|
| 74 |
+
"""returns the progress string at the current iteration"""
|
| 75 |
+
return self.write(self.current, True)
|
| 76 |
+
|
| 77 |
+
def __repr__(self):
|
| 78 |
+
return f'{self.__class__.__name__}({self.string} {self.first}-{self.last})'
|
| 79 |
+
|
| 80 |
+
def __getitem__(self, x):
|
| 81 |
+
"""
|
| 82 |
+
Prints and returns the progress string at iteration x
|
| 83 |
+
This accomplishes the same thing as write()
|
| 84 |
+
"""
|
| 85 |
+
return self.write(x)
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def perc(self):
|
| 89 |
+
try:
|
| 90 |
+
return round(
|
| 91 |
+
(float(self.current) - self.first)
|
| 92 |
+
/ float(self.last - self.first)
|
| 93 |
+
* 100.,
|
| 94 |
+
1
|
| 95 |
+
)
|
| 96 |
+
except ZeroDivisionError:
|
| 97 |
+
return 0.
|
| 98 |
+
|
| 99 |
+
def write(self, current, suppress=False):
|
| 100 |
+
"""
|
| 101 |
+
Writes the progress of the iteration
|
| 102 |
+
|
| 103 |
+
:param current: current iteration
|
| 104 |
+
:param suppress: suppress output
|
| 105 |
+
:return: formatted output string
|
| 106 |
+
"""
|
| 107 |
+
self.count += 1 # keep count
|
| 108 |
+
if self.writeevery != 1:
|
| 109 |
+
# if the counter does not match the write, bail out
|
| 110 |
+
if self.count != self.last and self.count % self.writeevery != 0:
|
| 111 |
+
return None
|
| 112 |
+
self.current = current # saves the current state
|
| 113 |
+
string = f'{self.string}' # begin the string
|
| 114 |
+
if self.fraction is True:
|
| 115 |
+
string += f' #{current - self.first + 1}/{self.last - self.first + 1}'
|
| 116 |
+
if self.rng is True:
|
| 117 |
+
string += f' ({self.first}-{self.last})'
|
| 118 |
+
if self.percent is True:
|
| 119 |
+
string += f' {self.perc}%'
|
| 120 |
+
if self.hash is True:
|
| 121 |
+
string += self.hashes()
|
| 122 |
+
# create space filler if output has somehow shrunk below the length of the previous output
|
| 123 |
+
if len(string) < self.strlen:
|
| 124 |
+
string += ' ' * (self.strlen - len(string))
|
| 125 |
+
self.strlen = len(string)
|
| 126 |
+
# does not write string to terminal, instead returns the generated progress string
|
| 127 |
+
if suppress is True:
|
| 128 |
+
return string
|
| 129 |
+
try:
|
| 130 |
+
self.wr(f'\r{string}')
|
| 131 |
+
# a catch for I/O errors that sometimes pop up for large iteration processes
|
| 132 |
+
except ValueError:
|
| 133 |
+
pass
|
| 134 |
+
return string
|
| 135 |
+
|
| 136 |
+
def hashes(self):
|
| 137 |
+
"""generates the hash-type progress bar if called for"""
|
| 138 |
+
out = ' |'
|
| 139 |
+
num = int(self.perc / 100. * self.hashnum)
|
| 140 |
+
out += '#' * num # add completed
|
| 141 |
+
if num < self.hashnum: # add spinner
|
| 142 |
+
out += self.spinner[self.count % 4]
|
| 143 |
+
out += ' ' * (self.hashnum - len(out) + 2) # add still to go
|
| 144 |
+
out += '|'
|
| 145 |
+
return out
|
| 146 |
+
|
| 147 |
+
def fin(self, msg=None):
|
| 148 |
+
"""
|
| 149 |
+
writes the completion message of the object and starts a new line
|
| 150 |
+
if msg is specified, that message will be written instead of the object's
|
| 151 |
+
completion message
|
| 152 |
+
"""
|
| 153 |
+
if msg is None:
|
| 154 |
+
msg = self.endmsg
|
| 155 |
+
self.wr(f' {msg}\n')
|
| 156 |
+
self.fl() # flush output
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == '__main__': # for testing and troubleshooting
|
| 160 |
+
first = 1
|
| 161 |
+
finish = 255
|
| 162 |
+
prog = Progress(
|
| 163 |
+
first=first,
|
| 164 |
+
last=finish,
|
| 165 |
+
# hashes = True,
|
| 166 |
+
)
|
| 167 |
+
import time
|
| 168 |
+
|
| 169 |
+
for i in range(first, finish + 1):
|
| 170 |
+
prog.write(i)
|
| 171 |
+
time.sleep(0.01)
|
| 172 |
+
prog.fin()
|
lib/pythoms/scripttime.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ScriptTime class
|
| 3 |
+
records timepoints in a python script
|
| 4 |
+
|
| 5 |
+
new:
|
| 6 |
+
functional
|
| 7 |
+
added periter to print the average time per supplied number of iterations
|
| 8 |
+
created formattime to handle times less than 1 ms
|
| 9 |
+
removed secondstostr and replaced all calls with formattime
|
| 10 |
+
added function profiling
|
| 11 |
+
added toggle for profiling
|
| 12 |
+
changed from time.time() to time.clock() which seems to give much higher resolution
|
| 13 |
+
---1.0---
|
| 14 |
+
removed timepoint function (now redundant with profiling capability)
|
| 15 |
+
updated print profile data function to be more detailed and easier to read
|
| 16 |
+
---1.1---
|
| 17 |
+
---1.2
|
| 18 |
+
|
| 19 |
+
to add:
|
| 20 |
+
use time.time() in unix and time.clock() in windows
|
| 21 |
+
"""
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
import numpy as np
|
| 25 |
+
import datetime
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ScriptTime(object):
|
| 29 |
+
def __init__(self, profile=False):
|
| 30 |
+
"""
|
| 31 |
+
Class for storing timepoints in a python script
|
| 32 |
+
profile toggles whether the profiling functionality of the class will operate
|
| 33 |
+
"""
|
| 34 |
+
self._start_seconds = time.time() # start time (seconds since epoch)
|
| 35 |
+
self._end_seconds = None # end time (seconds since epoch)
|
| 36 |
+
self._start_clock = time.localtime() # start clock time
|
| 37 |
+
self._end_clock = None # end clock time
|
| 38 |
+
self.profile = profile # toggle for profiling functions
|
| 39 |
+
self.profiles = {}
|
| 40 |
+
|
| 41 |
+
def __str__(self):
|
| 42 |
+
"""The string that is returned when printed"""
|
| 43 |
+
return f'{self.__class__.__name__} initiated at {self.start_time}'
|
| 44 |
+
|
| 45 |
+
def __repr__(self):
|
| 46 |
+
"""The representation that is returned"""
|
| 47 |
+
return f'{self.__class__.__name__}({self.start_time})'
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def start_time(self):
|
| 51 |
+
return time.strftime("%I:%M:%S %p", self._start_clock)
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def end_time(self):
|
| 55 |
+
if self._end_clock is None:
|
| 56 |
+
return None
|
| 57 |
+
return time.strftime("%I:%M:%S %p", self._end_clock)
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def elapsed_time(self):
|
| 61 |
+
if self._end_seconds is None:
|
| 62 |
+
return time.time() - self._start_seconds
|
| 63 |
+
return self._end_seconds - self._start_seconds
|
| 64 |
+
|
| 65 |
+
def clearprofiles(self):
|
| 66 |
+
"""clears the profile data"""
|
| 67 |
+
self.profiles = {}
|
| 68 |
+
|
| 69 |
+
def formattime(self, t):
|
| 70 |
+
"""
|
| 71 |
+
Formats a time value in seconds to the appropriate string. This is now included for legacy support.
|
| 72 |
+
roughly based on formattime
|
| 73 |
+
"""
|
| 74 |
+
return str(datetime.timedelta(t))
|
| 75 |
+
|
| 76 |
+
def periter(self, num):
|
| 77 |
+
"""
|
| 78 |
+
calculated elapsed time per unit iteration (designed for timing scripts)
|
| 79 |
+
"""
|
| 80 |
+
sys.stdout.write('Average time per iteration: %s\n' % (self.formattime(self.elapsed_time / float(num))))
|
| 81 |
+
|
| 82 |
+
def printelapsed(self):
|
| 83 |
+
"""prints the elapsed time of the object"""
|
| 84 |
+
if self._end_seconds is None:
|
| 85 |
+
self.triggerend()
|
| 86 |
+
sys.stdout.write(f'Elapsed time: {datetime.timedelta(seconds=self.elapsed_time)}\n')
|
| 87 |
+
|
| 88 |
+
def printend(self):
|
| 89 |
+
"""prints the end time and the elapsed time of the object"""
|
| 90 |
+
if self._end_seconds is None:
|
| 91 |
+
self.triggerend()
|
| 92 |
+
sys.stdout.write(f'End time: {self.end_time} (elapsed: {datetime.timedelta(seconds=self.elapsed_time)})\n')
|
| 93 |
+
|
| 94 |
+
def printprofiles(self):
|
| 95 |
+
"""prints the data for the profiled functions"""
|
| 96 |
+
sys.stdout.write('\nFunction profile data:\n')
|
| 97 |
+
sys.stdout.write(
|
| 98 |
+
'%15s %6s %13s %13s %13s %13s\n' % ('function', 'called', 'avg', 'standard_deviation', 'max', 'min'))
|
| 99 |
+
for fname, data in self.profiles.items():
|
| 100 |
+
avg = sum(data[1]) / len(data[1])
|
| 101 |
+
sys.stdout.write('%15s %6d %13s %13s %13s %13s\n' % (
|
| 102 |
+
fname,
|
| 103 |
+
data[0],
|
| 104 |
+
self.formattime(avg),
|
| 105 |
+
self.formattime(np.sqrt(sum((i - avg) ** 2 for i in data[1]) / (len(data[1]) - 1))),
|
| 106 |
+
self.formattime(max(data[1])),
|
| 107 |
+
self.formattime(min(data[1]))
|
| 108 |
+
))
|
| 109 |
+
|
| 110 |
+
def printstart(self):
|
| 111 |
+
"""prints the start (trigger) time of the object"""
|
| 112 |
+
sys.stdout.write('Start time: %s\n' % (time.strftime('%I:%M:%S %p', self._start_clock)))
|
| 113 |
+
|
| 114 |
+
def profilefn(self, fn):
|
| 115 |
+
"""generates a profiled version of the supplied function"""
|
| 116 |
+
|
| 117 |
+
# from functools import wraps # unsure why these lines are present
|
| 118 |
+
# @wraps(fn)
|
| 119 |
+
def with_profiling(*args, **kwargs):
|
| 120 |
+
"""decorates function with profiling commands"""
|
| 121 |
+
start_time = time.perf_counter() # time that the function was called
|
| 122 |
+
ret = fn(*args, **kwargs) # calls the function
|
| 123 |
+
|
| 124 |
+
elapsed_time = time.perf_counter() - start_time # end time of the function
|
| 125 |
+
if fn.__name__ not in self.profiles: # generates a dictionary key based on the function name if not present
|
| 126 |
+
self.profiles[fn.__name__] = [0, []] # [number of times called, [list of durations]]
|
| 127 |
+
self.profiles[fn.__name__][0] += 1
|
| 128 |
+
self.profiles[fn.__name__][1].append(elapsed_time)
|
| 129 |
+
return ret # returns the calculated call of the function
|
| 130 |
+
|
| 131 |
+
if self.profile is True:
|
| 132 |
+
return with_profiling # returns the decorated function
|
| 133 |
+
else:
|
| 134 |
+
return fn
|
| 135 |
+
|
| 136 |
+
def triggerend(self):
|
| 137 |
+
"""triggers endpoint and calculates elapsed time since start"""
|
| 138 |
+
self._end_seconds = time.time()
|
| 139 |
+
self._end_clock = time.localtime()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
if __name__ == '__main__':
|
| 143 |
+
st = ScriptTime()
|
| 144 |
+
time.sleep(2.)
|
| 145 |
+
st.triggerend()
|
| 146 |
+
st.printend()
|
lib/pythoms/senko_charge_assignment.py
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Automated Assignment of Charge States from Resolved Isotopic Peaks
|
| 3 |
+
|
| 4 |
+
Implementation of methods from:
|
| 5 |
+
Senko, M.W., Beu, S.C., and McLafferty, F.W. (1995)
|
| 6 |
+
"Automated Assignment of Charge States from Resolved Isotopic Peaks for Multiply Charged Ions"
|
| 7 |
+
J. Am. Soc. Mass Spectrom., 6, 52-56
|
| 8 |
+
|
| 9 |
+
This module provides three complementary algorithms for charge state determination:
|
| 10 |
+
1. Patterson Function - Best for low charge states (z < 5) with high S/N
|
| 11 |
+
2. Fourier Transform - Best for high charge states (z > 5) with low resolving power
|
| 12 |
+
3. Combination Method - Multiplies Patterson × Fourier (recommended for all cases)
|
| 13 |
+
|
| 14 |
+
The methods achieved >95% accuracy in the original paper and work even when
|
| 15 |
+
isotope clusters overlap.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
from scipy.interpolate import interp1d
|
| 20 |
+
from scipy.signal import find_peaks
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def patterson_function(mz_array, intensity_array, charge_range=(1, 10), step_size=1/3):
|
| 24 |
+
"""
|
| 25 |
+
Patterson function for charge state determination.
|
| 26 |
+
|
| 27 |
+
Best for low charge states (z < 5) with high S/N and resolving power.
|
| 28 |
+
|
| 29 |
+
From the paper:
|
| 30 |
+
P(ΔM) = Σ f(Mi - ΔM/2) * f(Mi + ΔM/2)
|
| 31 |
+
|
| 32 |
+
where ΔM is the inverse of the charge being evaluated.
|
| 33 |
+
|
| 34 |
+
Parameters:
|
| 35 |
+
-----------
|
| 36 |
+
mz_array : np.ndarray
|
| 37 |
+
m/z values of the isotope envelope
|
| 38 |
+
intensity_array : np.ndarray
|
| 39 |
+
Intensity values
|
| 40 |
+
charge_range : tuple
|
| 41 |
+
(min_charge, max_charge) to test
|
| 42 |
+
step_size : float
|
| 43 |
+
Step size for charge evaluation (default 1/3 for smooth maps)
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
--------
|
| 47 |
+
charges : np.ndarray
|
| 48 |
+
Array of charge values tested
|
| 49 |
+
patterson_map : np.ndarray
|
| 50 |
+
Patterson function values for each charge
|
| 51 |
+
"""
|
| 52 |
+
min_z, max_z = charge_range
|
| 53 |
+
|
| 54 |
+
# Create interpolation function for intensity
|
| 55 |
+
# Use linear interpolation between data points
|
| 56 |
+
interp_func = interp1d(mz_array, intensity_array, kind='linear',
|
| 57 |
+
bounds_error=False, fill_value=0.0)
|
| 58 |
+
|
| 59 |
+
# Generate charge values to test (with fractional steps for smooth map)
|
| 60 |
+
charges = np.arange(min_z - 1/3, max_z + 1, step_size)
|
| 61 |
+
patterson_map = np.zeros(len(charges))
|
| 62 |
+
|
| 63 |
+
for idx, z in enumerate(charges):
|
| 64 |
+
if z < 1:
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
delta_m = 1.0 / z # Spacing for this charge state
|
| 68 |
+
|
| 69 |
+
# Calculate Patterson function
|
| 70 |
+
# Sum over all m/z points
|
| 71 |
+
patterson_sum = 0.0
|
| 72 |
+
for mz in mz_array:
|
| 73 |
+
# Get intensities at mz - delta_m/2 and mz + delta_m/2
|
| 74 |
+
I_minus = interp_func(mz - delta_m / 2)
|
| 75 |
+
I_plus = interp_func(mz + delta_m / 2)
|
| 76 |
+
patterson_sum += I_minus * I_plus
|
| 77 |
+
|
| 78 |
+
patterson_map[idx] = patterson_sum
|
| 79 |
+
|
| 80 |
+
return charges, patterson_map
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def fourier_function(mz_array, intensity_array, charge_range=(1, 10)):
|
| 84 |
+
"""
|
| 85 |
+
Fourier transform method for charge state determination.
|
| 86 |
+
|
| 87 |
+
Best for high charge states (z > 5) with low resolving power.
|
| 88 |
+
Produces sharper peaks than Patterson method.
|
| 89 |
+
|
| 90 |
+
The FFT considers isotopic peaks in terms of their frequency of occurrence,
|
| 91 |
+
not their spacing. The repetitive spacing produces a maximum in the frequency domain.
|
| 92 |
+
|
| 93 |
+
Parameters:
|
| 94 |
+
-----------
|
| 95 |
+
mz_array : np.ndarray
|
| 96 |
+
m/z values of the isotope envelope
|
| 97 |
+
intensity_array : np.ndarray
|
| 98 |
+
Intensity values
|
| 99 |
+
charge_range : tuple
|
| 100 |
+
(min_charge, max_charge) to test
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
--------
|
| 104 |
+
charges : np.ndarray
|
| 105 |
+
Array of charge values
|
| 106 |
+
fourier_map : np.ndarray
|
| 107 |
+
Fourier transform magnitude for each charge
|
| 108 |
+
"""
|
| 109 |
+
min_z, max_z = charge_range
|
| 110 |
+
|
| 111 |
+
# Baseline correction - subtract minimum
|
| 112 |
+
baseline = np.min(intensity_array)
|
| 113 |
+
corrected_intensity = intensity_array - baseline
|
| 114 |
+
|
| 115 |
+
# Pad data to next power of 2 for efficient FFT
|
| 116 |
+
n_points = len(corrected_intensity)
|
| 117 |
+
n_padded = 2 ** int(np.ceil(np.log2(n_points)))
|
| 118 |
+
padded_intensity = np.zeros(n_padded)
|
| 119 |
+
padded_intensity[:n_points] = corrected_intensity
|
| 120 |
+
|
| 121 |
+
# Perform FFT
|
| 122 |
+
fft_result = np.fft.fft(padded_intensity)
|
| 123 |
+
fft_magnitude = np.abs(fft_result)
|
| 124 |
+
|
| 125 |
+
# Get frequency axis
|
| 126 |
+
# The m/z spacing
|
| 127 |
+
mz_spacing = np.mean(np.diff(mz_array))
|
| 128 |
+
frequencies = np.fft.fftfreq(n_padded, d=mz_spacing)
|
| 129 |
+
|
| 130 |
+
# Convert frequencies to charge states
|
| 131 |
+
# Isotope spacing = 1.003 / z (approximately 1/z)
|
| 132 |
+
# Frequency = 1 / spacing = z / 1.003
|
| 133 |
+
# So: z ≈ frequency * 1.003
|
| 134 |
+
|
| 135 |
+
# Map FFT results to charge states
|
| 136 |
+
charges = np.arange(min_z, max_z + 1)
|
| 137 |
+
fourier_map = np.zeros(len(charges))
|
| 138 |
+
|
| 139 |
+
for idx, z in enumerate(charges):
|
| 140 |
+
# Expected frequency for this charge
|
| 141 |
+
expected_freq = z / 1.003
|
| 142 |
+
|
| 143 |
+
# Find closest frequency in FFT
|
| 144 |
+
freq_idx = np.argmin(np.abs(frequencies - expected_freq))
|
| 145 |
+
fourier_map[idx] = fft_magnitude[freq_idx]
|
| 146 |
+
|
| 147 |
+
return charges, fourier_map
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def combination_function(mz_array, intensity_array, charge_range=(1, 10)):
|
| 151 |
+
"""
|
| 152 |
+
Combination method: Patterson × Fourier.
|
| 153 |
+
|
| 154 |
+
RECOMMENDED for all cases. Achieves >95% accuracy.
|
| 155 |
+
|
| 156 |
+
From the paper:
|
| 157 |
+
C(z) = F(z) * P(z)
|
| 158 |
+
|
| 159 |
+
Only the true maximum should be present in both maps, and thus should be
|
| 160 |
+
most abundant in the combination map. This reduces false maxima from both methods.
|
| 161 |
+
|
| 162 |
+
Parameters:
|
| 163 |
+
-----------
|
| 164 |
+
mz_array : np.ndarray
|
| 165 |
+
m/z values of the isotope envelope
|
| 166 |
+
intensity_array : np.ndarray
|
| 167 |
+
Intensity values
|
| 168 |
+
charge_range : tuple
|
| 169 |
+
(min_charge, max_charge) to test
|
| 170 |
+
|
| 171 |
+
Returns:
|
| 172 |
+
--------
|
| 173 |
+
charges : np.ndarray
|
| 174 |
+
Array of charge values
|
| 175 |
+
combination_map : np.ndarray
|
| 176 |
+
Combined Patterson × Fourier values
|
| 177 |
+
patterson_map : np.ndarray
|
| 178 |
+
Patterson function values
|
| 179 |
+
fourier_map : np.ndarray
|
| 180 |
+
Fourier transform values
|
| 181 |
+
"""
|
| 182 |
+
# Get Patterson map
|
| 183 |
+
charges_p, patterson_map = patterson_function(mz_array, intensity_array, charge_range)
|
| 184 |
+
|
| 185 |
+
# Get Fourier map (interpolate to match Patterson charges)
|
| 186 |
+
charges_f, fourier_map_raw = fourier_function(mz_array, intensity_array, charge_range)
|
| 187 |
+
|
| 188 |
+
# Interpolate Fourier to match Patterson charge grid
|
| 189 |
+
fourier_interp = interp1d(charges_f, fourier_map_raw, kind='linear',
|
| 190 |
+
bounds_error=False, fill_value=0.0)
|
| 191 |
+
fourier_map = fourier_interp(charges_p)
|
| 192 |
+
|
| 193 |
+
# Normalize both maps to [0, 1]
|
| 194 |
+
if np.max(patterson_map) > 0:
|
| 195 |
+
patterson_norm = patterson_map / np.max(patterson_map)
|
| 196 |
+
else:
|
| 197 |
+
patterson_norm = patterson_map
|
| 198 |
+
|
| 199 |
+
if np.max(fourier_map) > 0:
|
| 200 |
+
fourier_norm = fourier_map / np.max(fourier_map)
|
| 201 |
+
else:
|
| 202 |
+
fourier_norm = fourier_map
|
| 203 |
+
|
| 204 |
+
# Multiply the two maps
|
| 205 |
+
combination_map = patterson_norm * fourier_norm
|
| 206 |
+
|
| 207 |
+
return charges_p, combination_map, patterson_norm, fourier_norm
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def find_envelope_boundaries(mz_array, intensity_array, valley_threshold=0.02):
|
| 211 |
+
"""
|
| 212 |
+
Find isotope envelope boundaries by locating global apex and global valleys.
|
| 213 |
+
|
| 214 |
+
Algorithm:
|
| 215 |
+
1. Find the global apex (highest point)
|
| 216 |
+
2. Smooth the signal to get envelope shape (ignore local isotope oscillations)
|
| 217 |
+
3. Go left/right from apex until smoothed intensity drops below threshold
|
| 218 |
+
|
| 219 |
+
This finds the true envelope boundaries, not local valleys between isotope peaks.
|
| 220 |
+
|
| 221 |
+
Parameters:
|
| 222 |
+
-----------
|
| 223 |
+
mz_array : np.ndarray
|
| 224 |
+
m/z values
|
| 225 |
+
intensity_array : np.ndarray
|
| 226 |
+
Intensity values
|
| 227 |
+
valley_threshold : float
|
| 228 |
+
Valley is found when intensity drops below this fraction of max (default 0.02 = 2%)
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
--------
|
| 232 |
+
dict with:
|
| 233 |
+
- 'global_apex_idx': int, index of global apex
|
| 234 |
+
- 'left_valley_idx': int, index of left boundary
|
| 235 |
+
- 'right_valley_idx': int, index of right boundary
|
| 236 |
+
- 'envelope_mz': np.ndarray, m/z values within envelope
|
| 237 |
+
- 'envelope_intensity': np.ndarray, intensity values within envelope
|
| 238 |
+
"""
|
| 239 |
+
if len(mz_array) < 3:
|
| 240 |
+
return {
|
| 241 |
+
'global_apex_idx': 0,
|
| 242 |
+
'left_valley_idx': 0,
|
| 243 |
+
'right_valley_idx': len(mz_array) - 1,
|
| 244 |
+
'envelope_mz': mz_array,
|
| 245 |
+
'envelope_intensity': intensity_array
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
# Find global apex
|
| 249 |
+
global_apex_idx = np.argmax(intensity_array)
|
| 250 |
+
max_intensity = intensity_array[global_apex_idx]
|
| 251 |
+
threshold = max_intensity * valley_threshold
|
| 252 |
+
|
| 253 |
+
# Smooth the signal to find envelope shape
|
| 254 |
+
# Use a wider window to smooth over isotope peak oscillations
|
| 255 |
+
mz_span = mz_array[-1] - mz_array[0]
|
| 256 |
+
points_per_mz = len(mz_array) / mz_span if mz_span > 0 else 10
|
| 257 |
+
window_size = max(5, int(points_per_mz * 0.5)) # ~0.5 m/z window
|
| 258 |
+
if window_size % 2 == 0:
|
| 259 |
+
window_size += 1
|
| 260 |
+
window_size = min(window_size, len(intensity_array) // 3) # Don't make window too big
|
| 261 |
+
|
| 262 |
+
# Pad and smooth using convolution
|
| 263 |
+
half_win = window_size // 2
|
| 264 |
+
padded = np.pad(intensity_array, half_win, mode='edge')
|
| 265 |
+
kernel = np.ones(window_size) / window_size
|
| 266 |
+
smoothed = np.convolve(padded, kernel, mode='valid')
|
| 267 |
+
|
| 268 |
+
# Ensure smoothed is same length as input
|
| 269 |
+
if len(smoothed) > len(intensity_array):
|
| 270 |
+
smoothed = smoothed[:len(intensity_array)]
|
| 271 |
+
elif len(smoothed) < len(intensity_array):
|
| 272 |
+
smoothed = np.pad(smoothed, (0, len(intensity_array) - len(smoothed)), mode='edge')
|
| 273 |
+
|
| 274 |
+
# Go left to find left valley (using smoothed signal)
|
| 275 |
+
left_valley_idx = 0
|
| 276 |
+
for i in range(global_apex_idx - 1, -1, -1):
|
| 277 |
+
if smoothed[i] < threshold:
|
| 278 |
+
left_valley_idx = i
|
| 279 |
+
break
|
| 280 |
+
|
| 281 |
+
# Go right to find right valley (using smoothed signal)
|
| 282 |
+
right_valley_idx = len(intensity_array) - 1
|
| 283 |
+
for i in range(global_apex_idx + 1, len(intensity_array)):
|
| 284 |
+
if smoothed[i] < threshold:
|
| 285 |
+
right_valley_idx = i
|
| 286 |
+
break
|
| 287 |
+
|
| 288 |
+
return {
|
| 289 |
+
'global_apex_idx': global_apex_idx,
|
| 290 |
+
'left_valley_idx': left_valley_idx,
|
| 291 |
+
'right_valley_idx': right_valley_idx,
|
| 292 |
+
'envelope_mz': mz_array[left_valley_idx:right_valley_idx+1],
|
| 293 |
+
'envelope_intensity': intensity_array[left_valley_idx:right_valley_idx+1]
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def extract_apexes(mz_array, intensity_array, min_prominence_ratio=0.05):
|
| 298 |
+
"""
|
| 299 |
+
Extract local maxima (apexes) from a spectrum region.
|
| 300 |
+
|
| 301 |
+
These apexes represent the individual isotope peaks within an envelope.
|
| 302 |
+
Using apexes instead of raw data improves charge detection for complex
|
| 303 |
+
spectra like Duplex DNA where broad envelopes can confuse the algorithms.
|
| 304 |
+
|
| 305 |
+
Parameters:
|
| 306 |
+
-----------
|
| 307 |
+
mz_array : np.ndarray
|
| 308 |
+
m/z values of the region
|
| 309 |
+
intensity_array : np.ndarray
|
| 310 |
+
Intensity values of the region
|
| 311 |
+
min_prominence_ratio : float
|
| 312 |
+
Minimum prominence as fraction of max intensity (default 0.05 = 5%)
|
| 313 |
+
|
| 314 |
+
Returns:
|
| 315 |
+
--------
|
| 316 |
+
apex_mz : np.ndarray
|
| 317 |
+
m/z values of the apexes
|
| 318 |
+
apex_intensity : np.ndarray
|
| 319 |
+
Intensity values of the apexes
|
| 320 |
+
apex_indices : np.ndarray
|
| 321 |
+
Indices of apexes in the original arrays
|
| 322 |
+
"""
|
| 323 |
+
if len(mz_array) < 3:
|
| 324 |
+
return mz_array, intensity_array, np.arange(len(mz_array))
|
| 325 |
+
|
| 326 |
+
max_intensity = np.max(intensity_array)
|
| 327 |
+
min_prominence = max_intensity * min_prominence_ratio
|
| 328 |
+
|
| 329 |
+
# Find local maxima with sufficient prominence
|
| 330 |
+
apex_indices, properties = find_peaks(
|
| 331 |
+
intensity_array,
|
| 332 |
+
prominence=min_prominence,
|
| 333 |
+
distance=2
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
# If no apexes found, fall back to using the maximum point
|
| 337 |
+
if len(apex_indices) == 0:
|
| 338 |
+
max_idx = np.argmax(intensity_array)
|
| 339 |
+
apex_indices = np.array([max_idx])
|
| 340 |
+
|
| 341 |
+
apex_mz = mz_array[apex_indices]
|
| 342 |
+
apex_intensity = intensity_array[apex_indices]
|
| 343 |
+
|
| 344 |
+
return apex_mz, apex_intensity, apex_indices
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def assign_charge_senko(mz_array, intensity_array, charge_range=(1, 10),
|
| 348 |
+
method='combination', return_all_maps=False):
|
| 349 |
+
"""
|
| 350 |
+
Assign charge state using Senko et al. 1995 methods.
|
| 351 |
+
|
| 352 |
+
This is the main function to call for charge state assignment.
|
| 353 |
+
|
| 354 |
+
Parameters:
|
| 355 |
+
-----------
|
| 356 |
+
mz_array : np.ndarray
|
| 357 |
+
m/z values of the isotope envelope
|
| 358 |
+
intensity_array : np.ndarray
|
| 359 |
+
Intensity values
|
| 360 |
+
charge_range : tuple
|
| 361 |
+
(min_charge, max_charge) to test
|
| 362 |
+
method : str
|
| 363 |
+
'patterson', 'fourier', or 'combination' (recommended)
|
| 364 |
+
return_all_maps : bool
|
| 365 |
+
If True, return all charge maps for visualization
|
| 366 |
+
|
| 367 |
+
Returns:
|
| 368 |
+
--------
|
| 369 |
+
dict with keys:
|
| 370 |
+
- 'charge': int, assigned charge state
|
| 371 |
+
- 'confidence': float, normalized score for assigned charge
|
| 372 |
+
- 'method': str, method used
|
| 373 |
+
- 'charge_map': dict with charges and scores (if return_all_maps=True)
|
| 374 |
+
"""
|
| 375 |
+
if len(mz_array) < 2:
|
| 376 |
+
return {
|
| 377 |
+
'charge': None,
|
| 378 |
+
'confidence': 0.0,
|
| 379 |
+
'method': method,
|
| 380 |
+
'error': 'Insufficient data points'
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
# Choose method
|
| 384 |
+
if method == 'patterson':
|
| 385 |
+
charges, charge_map = patterson_function(mz_array, intensity_array, charge_range)
|
| 386 |
+
elif method == 'fourier':
|
| 387 |
+
charges, charge_map = fourier_function(mz_array, intensity_array, charge_range)
|
| 388 |
+
elif method == 'combination':
|
| 389 |
+
charges, charge_map, patterson_map, fourier_map = combination_function(
|
| 390 |
+
mz_array, intensity_array, charge_range
|
| 391 |
+
)
|
| 392 |
+
else:
|
| 393 |
+
raise ValueError(f"Unknown method: {method}")
|
| 394 |
+
|
| 395 |
+
# Find charge with maximum score
|
| 396 |
+
max_idx = np.argmax(charge_map)
|
| 397 |
+
assigned_charge = charges[max_idx]
|
| 398 |
+
|
| 399 |
+
# Round to nearest integer
|
| 400 |
+
assigned_charge = int(round(assigned_charge))
|
| 401 |
+
|
| 402 |
+
# Calculate confidence (normalized score)
|
| 403 |
+
if np.max(charge_map) > 0:
|
| 404 |
+
confidence = charge_map[max_idx] / np.max(charge_map)
|
| 405 |
+
else:
|
| 406 |
+
confidence = 0.0
|
| 407 |
+
|
| 408 |
+
result = {
|
| 409 |
+
'charge': assigned_charge,
|
| 410 |
+
'confidence': float(confidence),
|
| 411 |
+
'method': method
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
if return_all_maps:
|
| 415 |
+
result['charge_map'] = {
|
| 416 |
+
'charges': charges.tolist(),
|
| 417 |
+
'scores': charge_map.tolist()
|
| 418 |
+
}
|
| 419 |
+
if method == 'combination':
|
| 420 |
+
result['patterson_map'] = patterson_map.tolist()
|
| 421 |
+
result['fourier_map'] = fourier_map.tolist()
|
| 422 |
+
|
| 423 |
+
return result
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def extract_isotope_envelope(mz_array, intensity_array, peak_mz, window=2.0):
|
| 427 |
+
"""
|
| 428 |
+
Extract an isotope envelope around a peak for charge state analysis.
|
| 429 |
+
|
| 430 |
+
Parameters:
|
| 431 |
+
-----------
|
| 432 |
+
mz_array : np.ndarray
|
| 433 |
+
Full m/z array
|
| 434 |
+
intensity_array : np.ndarray
|
| 435 |
+
Full intensity array
|
| 436 |
+
peak_mz : float
|
| 437 |
+
Center m/z of the peak
|
| 438 |
+
window : float
|
| 439 |
+
Window size in m/z units (±window from peak_mz)
|
| 440 |
+
|
| 441 |
+
Returns:
|
| 442 |
+
--------
|
| 443 |
+
envelope_mz : np.ndarray
|
| 444 |
+
m/z values in the envelope
|
| 445 |
+
envelope_intensity : np.ndarray
|
| 446 |
+
Intensity values in the envelope
|
| 447 |
+
"""
|
| 448 |
+
# Find region around peak
|
| 449 |
+
mask = (mz_array >= peak_mz - window) & (mz_array <= peak_mz + window)
|
| 450 |
+
envelope_mz = mz_array[mask]
|
| 451 |
+
envelope_intensity = intensity_array[mask]
|
| 452 |
+
|
| 453 |
+
return envelope_mz, envelope_intensity
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def find_peak_regions(mz_values, intensity_values, threshold=0.05, merge_gap=1.5):
|
| 457 |
+
"""
|
| 458 |
+
Find isotope envelope regions using LOCAL MAXIMA detection.
|
| 459 |
+
|
| 460 |
+
Parameters:
|
| 461 |
+
-----------
|
| 462 |
+
mz_values : np.ndarray
|
| 463 |
+
m/z values
|
| 464 |
+
intensity_values : np.ndarray
|
| 465 |
+
Intensity values
|
| 466 |
+
threshold : float
|
| 467 |
+
Relative intensity threshold (0-1) - peaks below this are ignored
|
| 468 |
+
merge_gap : float
|
| 469 |
+
Merge regions separated by less than this m/z (same isotope envelope)
|
| 470 |
+
|
| 471 |
+
Returns:
|
| 472 |
+
--------
|
| 473 |
+
list of tuples (start_idx, end_idx) for each region
|
| 474 |
+
"""
|
| 475 |
+
if len(mz_values) < 5:
|
| 476 |
+
return []
|
| 477 |
+
|
| 478 |
+
max_intensity = np.max(intensity_values)
|
| 479 |
+
mz_spacing = np.median(np.diff(mz_values))
|
| 480 |
+
|
| 481 |
+
# Estimate noise floor from the spectrum median (baseline)
|
| 482 |
+
noise_floor = np.median(intensity_values)
|
| 483 |
+
noise_threshold = noise_floor * 3 # 3× median as noise cutoff
|
| 484 |
+
|
| 485 |
+
print(f"[find_peak_regions] Max intensity: {max_intensity:.0f}, noise floor: {noise_floor:.0f}, noise threshold: {noise_threshold:.0f}, mz_spacing: {mz_spacing:.4f}")
|
| 486 |
+
|
| 487 |
+
min_height = max(max_intensity * threshold, noise_threshold)
|
| 488 |
+
min_prominence = max(min_height * 0.5, noise_threshold)
|
| 489 |
+
min_distance = max(10, int(10.0 / mz_spacing))
|
| 490 |
+
|
| 491 |
+
# Find peaks
|
| 492 |
+
peak_indices, properties = find_peaks(
|
| 493 |
+
intensity_values,
|
| 494 |
+
height=min_height,
|
| 495 |
+
prominence=min_prominence,
|
| 496 |
+
distance=min_distance
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
print(f"[find_peak_regions] height_threshold={min_height:.0f}, distance={min_distance} indices")
|
| 500 |
+
print(f"[find_peak_regions] Found {len(peak_indices)} peaks above threshold")
|
| 501 |
+
if len(peak_indices) > 0:
|
| 502 |
+
# Show top 5 peaks by intensity
|
| 503 |
+
peak_ints = intensity_values[peak_indices]
|
| 504 |
+
top_5_idx = np.argsort(peak_ints)[-5:][::-1] # Get indices of top 5
|
| 505 |
+
print(f"[find_peak_regions] Top peaks: ", end="")
|
| 506 |
+
for i in top_5_idx:
|
| 507 |
+
if i < len(peak_indices):
|
| 508 |
+
mz = mz_values[peak_indices[i]]
|
| 509 |
+
inten = intensity_values[peak_indices[i]]
|
| 510 |
+
print(f"m/z={mz:.1f}(I={inten:.0f}), ", end="")
|
| 511 |
+
print()
|
| 512 |
+
|
| 513 |
+
if len(peak_indices) == 0:
|
| 514 |
+
# Fallback: try with lower requirements
|
| 515 |
+
peak_indices, properties = find_peaks(
|
| 516 |
+
intensity_values,
|
| 517 |
+
height=min_height * 0.5,
|
| 518 |
+
prominence=min_prominence * 0.5,
|
| 519 |
+
distance=min_distance // 2
|
| 520 |
+
)
|
| 521 |
+
|
| 522 |
+
if len(peak_indices) == 0:
|
| 523 |
+
return []
|
| 524 |
+
|
| 525 |
+
# For each detected peak, create a region around it (±5 m/z window)
|
| 526 |
+
# This captures the isotope envelope while avoiding merging nearby envelopes
|
| 527 |
+
envelope_half_width = 5.0 # m/z
|
| 528 |
+
envelope_half_idx = int(envelope_half_width / mz_spacing)
|
| 529 |
+
|
| 530 |
+
regions = []
|
| 531 |
+
for peak_idx in peak_indices:
|
| 532 |
+
left_idx = max(0, peak_idx - envelope_half_idx)
|
| 533 |
+
right_idx = min(len(mz_values) - 1, peak_idx + envelope_half_idx)
|
| 534 |
+
regions.append((left_idx, right_idx))
|
| 535 |
+
|
| 536 |
+
if len(regions) <= 1:
|
| 537 |
+
return regions
|
| 538 |
+
|
| 539 |
+
# Merge overlapping or close regions (same isotope envelope)
|
| 540 |
+
regions.sort(key=lambda x: x[0])
|
| 541 |
+
|
| 542 |
+
merged_regions = []
|
| 543 |
+
current_start, current_end = regions[0]
|
| 544 |
+
|
| 545 |
+
for i in range(1, len(regions)):
|
| 546 |
+
next_start, next_end = regions[i]
|
| 547 |
+
|
| 548 |
+
# Check for overlap or small gap
|
| 549 |
+
gap = mz_values[next_start] - mz_values[current_end] if next_start > current_end else 0
|
| 550 |
+
|
| 551 |
+
if next_start <= current_end or gap < merge_gap:
|
| 552 |
+
# Merge: extend current region
|
| 553 |
+
current_end = max(current_end, next_end)
|
| 554 |
+
else:
|
| 555 |
+
# Save current region and start new one
|
| 556 |
+
merged_regions.append((current_start, current_end))
|
| 557 |
+
current_start, current_end = next_start, next_end
|
| 558 |
+
|
| 559 |
+
merged_regions.append((current_start, current_end))
|
| 560 |
+
|
| 561 |
+
# print(f"[find_peak_regions] After merging: {len(merged_regions)} regions")
|
| 562 |
+
# for i, (s, e) in enumerate(merged_regions[:5]): # Print first 5
|
| 563 |
+
# print(f" Region {i+1}: m/z {mz_values[s]:.1f} - {mz_values[e]:.1f}")
|
| 564 |
+
|
| 565 |
+
return merged_regions
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def weighted_centroid(mz_values, intensity_values, start_idx, end_idx):
|
| 569 |
+
"""
|
| 570 |
+
Calculate peak centroid (m/z at maximum intensity).
|
| 571 |
+
|
| 572 |
+
Returns:
|
| 573 |
+
--------
|
| 574 |
+
centroid_mz : float
|
| 575 |
+
m/z at maximum intensity
|
| 576 |
+
max_intensity : float
|
| 577 |
+
Maximum intensity in the region
|
| 578 |
+
"""
|
| 579 |
+
region_mz = mz_values[start_idx:end_idx+1]
|
| 580 |
+
region_int = intensity_values[start_idx:end_idx+1]
|
| 581 |
+
|
| 582 |
+
if len(region_mz) == 0 or np.sum(region_int) == 0:
|
| 583 |
+
return None, None
|
| 584 |
+
|
| 585 |
+
# Find the m/z at maximum intensity (peak apex)
|
| 586 |
+
max_idx = np.argmax(region_int)
|
| 587 |
+
centroid_mz = region_mz[max_idx]
|
| 588 |
+
max_intensity = region_int[max_idx]
|
| 589 |
+
|
| 590 |
+
return centroid_mz, max_intensity
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def measure_direct_spacing(mz_array, intensity_array):
|
| 594 |
+
"""
|
| 595 |
+
Determine charge by counting apexes in a 1 m/z window.
|
| 596 |
+
|
| 597 |
+
Simple and robust approach: since isotope spacing = 1.003/z,
|
| 598 |
+
the number of isotope peaks in a 1 m/z window equals the charge state.
|
| 599 |
+
|
| 600 |
+
Filters out noise spikes (peaks too close together) before counting.
|
| 601 |
+
|
| 602 |
+
Returns:
|
| 603 |
+
dict with 'spacing', 'charge', 'num_peaks', 'has_alternating_pattern'
|
| 604 |
+
"""
|
| 605 |
+
if len(mz_array) < 5:
|
| 606 |
+
return {'spacing': None, 'charge': None, 'num_peaks': 0, 'has_alternating_pattern': False}
|
| 607 |
+
|
| 608 |
+
# Extract apexes (local maxima) - use lower prominence for isotope peaks
|
| 609 |
+
peak_mzs, peak_ints, peaks = extract_apexes(mz_array, intensity_array, min_prominence_ratio=0.02)
|
| 610 |
+
|
| 611 |
+
if len(peaks) < 3:
|
| 612 |
+
return {'spacing': None, 'charge': None, 'num_peaks': len(peaks), 'has_alternating_pattern': False}
|
| 613 |
+
|
| 614 |
+
# Filter out noise spikes: peaks too close together (< 0.08 m/z) are likely noise
|
| 615 |
+
# For z=10, spacing would be ~0.1 m/z, so 0.08 is a safe minimum
|
| 616 |
+
MIN_SPACING = 0.08
|
| 617 |
+
filtered_mzs = [peak_mzs[0]]
|
| 618 |
+
filtered_ints = [peak_ints[0]]
|
| 619 |
+
|
| 620 |
+
for i in range(1, len(peak_mzs)):
|
| 621 |
+
spacing = peak_mzs[i] - filtered_mzs[-1]
|
| 622 |
+
if spacing >= MIN_SPACING:
|
| 623 |
+
# Normal spacing - keep this peak
|
| 624 |
+
filtered_mzs.append(peak_mzs[i])
|
| 625 |
+
filtered_ints.append(peak_ints[i])
|
| 626 |
+
else:
|
| 627 |
+
# Too close - keep the more intense one
|
| 628 |
+
if peak_ints[i] > filtered_ints[-1]:
|
| 629 |
+
filtered_mzs[-1] = peak_mzs[i]
|
| 630 |
+
filtered_ints[-1] = peak_ints[i]
|
| 631 |
+
|
| 632 |
+
filtered_mzs = np.array(filtered_mzs)
|
| 633 |
+
filtered_ints = np.array(filtered_ints)
|
| 634 |
+
|
| 635 |
+
if len(filtered_mzs) < 3:
|
| 636 |
+
return {'spacing': None, 'charge': None, 'num_peaks': len(filtered_mzs), 'has_alternating_pattern': False}
|
| 637 |
+
|
| 638 |
+
# COUNT APEXES IN 1 m/z WINDOW to determine charge
|
| 639 |
+
# Use multiple 1 m/z windows and take the most common count
|
| 640 |
+
mz_min = filtered_mzs[0]
|
| 641 |
+
mz_max = filtered_mzs[-1]
|
| 642 |
+
mz_span = mz_max - mz_min
|
| 643 |
+
|
| 644 |
+
if mz_span < 1.0:
|
| 645 |
+
# Envelope too small - count all peaks as the charge estimate
|
| 646 |
+
charge = len(filtered_mzs)
|
| 647 |
+
return {
|
| 648 |
+
'spacing': 1.003 / charge if charge > 0 else None,
|
| 649 |
+
'charge': charge,
|
| 650 |
+
'num_peaks': len(filtered_mzs),
|
| 651 |
+
'has_alternating_pattern': False
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
# Sample multiple 1 m/z windows centered at different positions
|
| 655 |
+
window_counts = []
|
| 656 |
+
step = 0.2 # Step through the envelope
|
| 657 |
+
|
| 658 |
+
for start_mz in np.arange(mz_min, mz_max - 1.0 + step, step):
|
| 659 |
+
end_mz = start_mz + 1.0
|
| 660 |
+
# Count peaks in this 1 m/z window
|
| 661 |
+
count = np.sum((filtered_mzs >= start_mz) & (filtered_mzs <= end_mz))
|
| 662 |
+
if count >= 1:
|
| 663 |
+
window_counts.append(count)
|
| 664 |
+
|
| 665 |
+
if len(window_counts) == 0:
|
| 666 |
+
return {'spacing': None, 'charge': None, 'num_peaks': len(filtered_mzs), 'has_alternating_pattern': False}
|
| 667 |
+
|
| 668 |
+
# Use the median count (robust to outliers at edges)
|
| 669 |
+
charge = int(round(np.median(window_counts)))
|
| 670 |
+
|
| 671 |
+
# Sanity check: charge should be between 1 and 10
|
| 672 |
+
charge = max(1, min(10, charge))
|
| 673 |
+
|
| 674 |
+
print(f" [measure_direct_spacing] Counted apexes in 1 m/z windows: {window_counts[:10]}... -> z={charge}")
|
| 675 |
+
|
| 676 |
+
has_overlap = False
|
| 677 |
+
if charge >= 4 and len(filtered_mzs) >= 6:
|
| 678 |
+
# Check step-2 spacings: if peak[i+2] - peak[i] gives charge/2, two species overlap
|
| 679 |
+
step2_spacings = filtered_mzs[2:] - filtered_mzs[:-2]
|
| 680 |
+
step2_median = np.median(step2_spacings)
|
| 681 |
+
half_charge = charge / 2.0
|
| 682 |
+
|
| 683 |
+
if step2_median > 0:
|
| 684 |
+
step2_z = 1.003 / step2_median
|
| 685 |
+
spacing_ok = abs(step2_z - half_charge) < 1.0
|
| 686 |
+
|
| 687 |
+
# Intensity balance: true overlap has comparable even/odd intensities
|
| 688 |
+
even_avg = np.mean(filtered_ints[0::2])
|
| 689 |
+
odd_avg = np.mean(filtered_ints[1::2])
|
| 690 |
+
intensity_balance = min(even_avg, odd_avg) / max(even_avg, odd_avg) if max(even_avg, odd_avg) > 0 else 0
|
| 691 |
+
|
| 692 |
+
# Envelope roughness: overlap creates jagged envelope, single species is smooth
|
| 693 |
+
mid_ints = filtered_ints[1:-1]
|
| 694 |
+
neighbor_avg = (filtered_ints[:-2] + filtered_ints[2:]) / 2
|
| 695 |
+
roughness = np.mean(np.abs(mid_ints - neighbor_avg)) / np.mean(filtered_ints) if np.mean(filtered_ints) > 0 else 0
|
| 696 |
+
|
| 697 |
+
print(f" [overlap check] step2_z={step2_z:.1f}, half_charge={half_charge:.1f}, "
|
| 698 |
+
f"spacing_ok={spacing_ok}, intensity_balance={intensity_balance:.2f}, roughness={roughness:.2f}")
|
| 699 |
+
|
| 700 |
+
if spacing_ok and intensity_balance > 0.3 and roughness > 0.15:
|
| 701 |
+
corrected_charge = int(round(step2_z))
|
| 702 |
+
corrected_charge = max(1, min(10, corrected_charge))
|
| 703 |
+
print(f" [overlap detection] Two overlapping species detected! "
|
| 704 |
+
f"step2_z={step2_z:.1f}, balance={intensity_balance:.2f}, roughness={roughness:.2f} -> corrected z={corrected_charge}")
|
| 705 |
+
charge = corrected_charge
|
| 706 |
+
has_overlap = True
|
| 707 |
+
|
| 708 |
+
return {
|
| 709 |
+
'spacing': 1.003 / charge,
|
| 710 |
+
'charge': charge,
|
| 711 |
+
'num_peaks': len(filtered_mzs),
|
| 712 |
+
'has_alternating_pattern': has_overlap
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
def detect_all_peaks_with_charge(mz_array, intensity_array,
|
| 717 |
+
prominence=0.05, charge_range=(1, 10),
|
| 718 |
+
method='combination', merge_gap=1.5):
|
| 719 |
+
"""
|
| 720 |
+
Detect all isotope envelopes (peak regions) in a spectrum and assign charge states.
|
| 721 |
+
|
| 722 |
+
Each isotope envelope (M, M+1, M+2, ...) is detected as ONE peak region
|
| 723 |
+
and assigned ONE charge state.
|
| 724 |
+
|
| 725 |
+
Parameters:
|
| 726 |
+
-----------
|
| 727 |
+
mz_array : np.ndarray
|
| 728 |
+
Full spectrum m/z values
|
| 729 |
+
intensity_array : np.ndarray
|
| 730 |
+
Full spectrum intensity values
|
| 731 |
+
prominence : float
|
| 732 |
+
Relative intensity threshold for region detection (0-1)
|
| 733 |
+
charge_range : tuple
|
| 734 |
+
(min_charge, max_charge) to test
|
| 735 |
+
method : str
|
| 736 |
+
'patterson', 'fourier', or 'combination'
|
| 737 |
+
merge_gap : float
|
| 738 |
+
Merge regions separated by less than this m/z (default 1.5)
|
| 739 |
+
|
| 740 |
+
Returns:
|
| 741 |
+
--------
|
| 742 |
+
list of dicts, each containing:
|
| 743 |
+
- 'mz': float, peak centroid m/z
|
| 744 |
+
- 'intensity': float, peak maximum intensity
|
| 745 |
+
- 'charge': int, assigned charge
|
| 746 |
+
- 'confidence': float, confidence score
|
| 747 |
+
- 'method': str, method used
|
| 748 |
+
"""
|
| 749 |
+
if len(mz_array) == 0:
|
| 750 |
+
return []
|
| 751 |
+
|
| 752 |
+
regions = find_peak_regions(mz_array, intensity_array, prominence, merge_gap)
|
| 753 |
+
|
| 754 |
+
print(f"[detect_all_peaks] Found {len(regions)} initial peak regions")
|
| 755 |
+
|
| 756 |
+
if len(regions) == 0:
|
| 757 |
+
return []
|
| 758 |
+
|
| 759 |
+
# For each region (isotope envelope), assign ONE charge
|
| 760 |
+
results = []
|
| 761 |
+
|
| 762 |
+
for start_idx, end_idx in regions:
|
| 763 |
+
# Get initial region data
|
| 764 |
+
region_mz = mz_array[start_idx:end_idx+1]
|
| 765 |
+
region_int = intensity_array[start_idx:end_idx+1]
|
| 766 |
+
|
| 767 |
+
# STEP 1: Find envelope boundaries using global apex → global valleys
|
| 768 |
+
# This refines the region to the actual isotope envelope (removes noise)
|
| 769 |
+
envelope = find_envelope_boundaries(region_mz, region_int)
|
| 770 |
+
envelope_mz = envelope['envelope_mz']
|
| 771 |
+
envelope_int = envelope['envelope_intensity']
|
| 772 |
+
|
| 773 |
+
# Use envelope data for analysis (refined boundaries)
|
| 774 |
+
if len(envelope_mz) >= 3:
|
| 775 |
+
analysis_mz = envelope_mz
|
| 776 |
+
analysis_int = envelope_int
|
| 777 |
+
else:
|
| 778 |
+
# Fall back to original region if envelope is too small
|
| 779 |
+
analysis_mz = region_mz
|
| 780 |
+
analysis_int = region_int
|
| 781 |
+
|
| 782 |
+
# Calculate centroid from the refined envelope
|
| 783 |
+
global_apex_idx = envelope['global_apex_idx']
|
| 784 |
+
centroid_mz = region_mz[global_apex_idx] if global_apex_idx < len(region_mz) else None
|
| 785 |
+
max_intensity = np.max(analysis_int) if len(analysis_int) > 0 else 0
|
| 786 |
+
|
| 787 |
+
if centroid_mz is None:
|
| 788 |
+
continue
|
| 789 |
+
|
| 790 |
+
# Skip if region is too small for reliable charge assignment
|
| 791 |
+
if len(analysis_mz) < 3:
|
| 792 |
+
# Skip this peak - not enough data points
|
| 793 |
+
print(f" Skipping peak at m/z {centroid_mz:.2f}: only {len(analysis_mz)} data points in envelope")
|
| 794 |
+
continue
|
| 795 |
+
|
| 796 |
+
# STEP 2: Assign charge using Senko method on the refined envelope
|
| 797 |
+
try:
|
| 798 |
+
charge_result = assign_charge_senko(
|
| 799 |
+
analysis_mz, analysis_int, charge_range, method
|
| 800 |
+
)
|
| 801 |
+
charge = charge_result['charge']
|
| 802 |
+
confidence = charge_result['confidence']
|
| 803 |
+
|
| 804 |
+
# STEP 3: VALIDATION using apex counting in 1 m/z window
|
| 805 |
+
spacing_result = measure_direct_spacing(analysis_mz, analysis_int)
|
| 806 |
+
if spacing_result['charge'] is not None and spacing_result['num_peaks'] >= 4:
|
| 807 |
+
spacing_charge = spacing_result['charge']
|
| 808 |
+
|
| 809 |
+
# If Senko gives low charge (z<=3) but apex counting gives high charge (z>=5),
|
| 810 |
+
# trust the apex counting - Senko often fails on complex Ag spectra
|
| 811 |
+
if charge <= 3 and spacing_charge >= 5:
|
| 812 |
+
print(f" Apex counting correction at m/z {centroid_mz:.2f}: z={charge} -> z={spacing_charge} (Senko gave implausibly low charge)")
|
| 813 |
+
charge = spacing_charge
|
| 814 |
+
confidence = 0.85
|
| 815 |
+
|
| 816 |
+
# If overlap detected (two interleaved species), use corrected charge
|
| 817 |
+
if spacing_result.get('has_alternating_pattern') and spacing_charge != charge:
|
| 818 |
+
print(f" Overlap correction at m/z {centroid_mz:.2f}: z={charge} -> z={spacing_charge} (two interleaved species)")
|
| 819 |
+
charge = spacing_charge
|
| 820 |
+
confidence = 0.90
|
| 821 |
+
|
| 822 |
+
# Add ALL peaks with valid charge assignments (no confidence threshold)
|
| 823 |
+
# Display confidence so users can judge reliability themselves
|
| 824 |
+
if charge is not None:
|
| 825 |
+
results.append({
|
| 826 |
+
'mz': float(centroid_mz),
|
| 827 |
+
'intensity': float(max_intensity),
|
| 828 |
+
'charge': charge,
|
| 829 |
+
'confidence': float(confidence),
|
| 830 |
+
'method': method
|
| 831 |
+
})
|
| 832 |
+
if confidence < 0.5:
|
| 833 |
+
print(f"Low confidence charge at m/z {centroid_mz:.2f}: z={charge}, confidence={confidence:.2f}")
|
| 834 |
+
else:
|
| 835 |
+
print(f"Detected charge at m/z {centroid_mz:.2f}: z={charge}, confidence={confidence:.2f}")
|
| 836 |
+
else:
|
| 837 |
+
# Skip only if charge assignment completely failed (returned None)
|
| 838 |
+
print(f"Skipping peak at m/z {centroid_mz:.2f} - charge assignment failed")
|
| 839 |
+
|
| 840 |
+
except Exception as e:
|
| 841 |
+
# Skip this peak - Senko algorithm failed with exception
|
| 842 |
+
print(f"Skipping peak at m/z {centroid_mz:.2f} - Error: {e}")
|
| 843 |
+
|
| 844 |
+
return results
|
| 845 |
+
|
| 846 |
+
|
| 847 |
+
# Example usage
|
| 848 |
+
if __name__ == '__main__':
|
| 849 |
+
print("Senko Charge Assignment Module")
|
| 850 |
+
print("=" * 60)
|
| 851 |
+
print("Based on: Senko et al., J. Am. Soc. Mass Spectrom. 1995, 6, 52-56")
|
| 852 |
+
print()
|
| 853 |
+
|
| 854 |
+
# Simulate an isotope envelope for z=3
|
| 855 |
+
# Isotope spacing = 1.003/3 ≈ 0.334 Da
|
| 856 |
+
mz_sim = np.array([1000.0, 1000.334, 1000.668, 1001.002, 1001.336])
|
| 857 |
+
# Gaussian-like envelope
|
| 858 |
+
intensity_sim = np.array([10, 45, 100, 75, 30])
|
| 859 |
+
|
| 860 |
+
print("Simulated isotope envelope (z=3):")
|
| 861 |
+
print(f" m/z spacing: ~{np.mean(np.diff(mz_sim)):.3f}")
|
| 862 |
+
print(f" Expected for z=3: {1.003/3:.3f}")
|
| 863 |
+
print()
|
| 864 |
+
|
| 865 |
+
# Test all three methods
|
| 866 |
+
for method in ['patterson', 'fourier', 'combination']:
|
| 867 |
+
result = assign_charge_senko(mz_sim, intensity_sim, charge_range=(1, 10), method=method)
|
| 868 |
+
print(f"{method.capitalize()} Method:")
|
| 869 |
+
print(f" Assigned charge: {result['charge']}")
|
| 870 |
+
print(f" Confidence: {result['confidence']:.3f}")
|
| 871 |
+
print()
|
| 872 |
+
|
| 873 |
+
print("=" * 60)
|
| 874 |
+
print("Module ready for integration!")
|
lib/pythoms/spectrum.py
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
This class is designed to efficiently combine, add to, or otherwise manipulate
|
| 3 |
+
spectra together whose dimensions are not equal.
|
| 4 |
+
For example, combining mass spectra together where resolution to the 3rd
|
| 5 |
+
decimal (not to the 10th decimal) is desired.
|
| 6 |
+
Upon initialization, specify the number of decimal places desired.
|
| 7 |
+
Start and end values for the x bounds may also be specified, and
|
| 8 |
+
an input spectrum can be provided (this spectrum will be added to
|
| 9 |
+
the object on initialization).
|
| 10 |
+
When adding a value to the Spectrum object, it will find the closest x value
|
| 11 |
+
with the decimal place specified and add the y value to that x in the object.
|
| 12 |
+
e.g. if the decimal place is 3, adding x=545.34898627,y=10 will add 10 to x=545.349
|
| 13 |
+
|
| 14 |
+
Once the desired spectrum has been constructed, calling Spectrum.trim() will return
|
| 15 |
+
an [[x values],[y values]] list with only the x values that have intensities. Other
|
| 16 |
+
manipulations are available, see below for details.
|
| 17 |
+
|
| 18 |
+
IGNORE:
|
| 19 |
+
CHANGELOG
|
| 20 |
+
---2.5---
|
| 21 |
+
- added the ability to not provide start and end points for an unfilled spectrum
|
| 22 |
+
---2.6
|
| 23 |
+
- added applycharge function to apply the charge to a mass list
|
| 24 |
+
IGNORE
|
| 25 |
+
"""
|
| 26 |
+
import numpy as np
|
| 27 |
+
from random import random
|
| 28 |
+
from bisect import bisect_left as bl
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def weighted_average(xvals, yvals):
|
| 32 |
+
"""
|
| 33 |
+
Determines the weighted average of a group of masses and abundances
|
| 34 |
+
|
| 35 |
+
:param list xvals: x values
|
| 36 |
+
:param list yvals: y values
|
| 37 |
+
:return: weighted average, summed intensity
|
| 38 |
+
:rtype: tuple of float
|
| 39 |
+
"""
|
| 40 |
+
if sum(yvals) == 0: # catch for no intensity
|
| 41 |
+
return sum(xvals) / len(xvals), 0.
|
| 42 |
+
return (
|
| 43 |
+
sum([x * y for x, y in zip(xvals, yvals)]) / sum(yvals), # weighted m/z
|
| 44 |
+
sum(yvals) # summed intensity
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def full_spectrum_list(start, end, decpl, filler=None):
|
| 49 |
+
"""
|
| 50 |
+
Generates two paired lists (one m/z, one None) from start to end with a specified number of decimal places.
|
| 51 |
+
|
| 52 |
+
:param float start: The start value for the x list.
|
| 53 |
+
:param float end: The end value for the x list.
|
| 54 |
+
:param int decpl: The decimal places to use for the generated list.
|
| 55 |
+
:param filler: The filler value for the y list
|
| 56 |
+
:return: A list of x values and a list of y values (specified by the ``filler`` keyword argument) of the
|
| 57 |
+
same length.
|
| 58 |
+
:rtype: tuple of lists
|
| 59 |
+
|
| 60 |
+
**Notes**
|
| 61 |
+
|
| 62 |
+
The maximum x value will be larger than end by 10^-``decpl`` to include the
|
| 63 |
+
actual end value in the x list.
|
| 64 |
+
|
| 65 |
+
"""
|
| 66 |
+
x = np.arange( # generate x values
|
| 67 |
+
start,
|
| 68 |
+
end + 10 ** -decpl,
|
| 69 |
+
10 ** -decpl
|
| 70 |
+
)
|
| 71 |
+
return (
|
| 72 |
+
x.tolist(), # convert to list
|
| 73 |
+
[filler] * len(x), # generate y list of equal length
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class Spectrum(object):
|
| 78 |
+
_start = -np.inf
|
| 79 |
+
_end = np.inf
|
| 80 |
+
_charge = 1
|
| 81 |
+
|
| 82 |
+
def __init__(self,
|
| 83 |
+
decpl,
|
| 84 |
+
start=50.,
|
| 85 |
+
end=2000.,
|
| 86 |
+
empty=False,
|
| 87 |
+
filler=None,
|
| 88 |
+
specin=None,
|
| 89 |
+
):
|
| 90 |
+
"""
|
| 91 |
+
A class for subtracting, combining, adding-to, and otherwise manipulating spectra with non-equal dimensions.
|
| 92 |
+
The object will track *x* values to a specified decimal place and can efficiently add a new value to a growing
|
| 93 |
+
list of values. e.g. adding two spectra together that do not have an intensity value for every *x* value
|
| 94 |
+
(a common operation for combining mass spectra). On initialization, specify the number of decimal places to
|
| 95 |
+
track using the ``decpl`` argument. Other behaviour of the class can be tweaked with the keyword arguments.
|
| 96 |
+
|
| 97 |
+
:param int decpl: The decimal places to track the *x* values two. e.g. a value of 3 will track
|
| 98 |
+
*x* values to the nearest 0.001.
|
| 99 |
+
:param float,None start: The minimum *x* value to track. Attempts to add an *x* value less than this
|
| 100 |
+
will be ignored by the instance.
|
| 101 |
+
:param float,None end: The maximum *x* value to track. Attempts to add an *x* value greater than this will be
|
| 102 |
+
ignored by the instance.
|
| 103 |
+
:param list specin: An spectrum to be added to the object on initialization. The format should be
|
| 104 |
+
``[[x values],[y values]]``.
|
| 105 |
+
:param bool empty: Whether the spectrum object should be filled or empty. An empty spectrum will have no *x*
|
| 106 |
+
or *y* values on initialization, and will add values with each call of ``Spectrum.addvalue()``. A filled
|
| 107 |
+
spectrum will generate an *x* list from *start* to *end* with spacing 10^-``decpl`` and a *y* list of equal
|
| 108 |
+
length filled with the value specified by the ``filler`` kwarg. If the number of items to be contained in
|
| 109 |
+
the spectrum is substantially less than ``(end-start)*10^decpl`` it can be more efficient to set this to
|
| 110 |
+
``True``. If not, then set this to False to reduce computational overhead.
|
| 111 |
+
:param filler: The y value to use if there is no y value. This can affect the functionality of some of the
|
| 112 |
+
functions in this class. If ``Spectrum.addelement()`` is to be used (e.g. by the Molecule class),
|
| 113 |
+
filler must be ``0.``.
|
| 114 |
+
|
| 115 |
+
**Basic Examples**
|
| 116 |
+
|
| 117 |
+
Specify the number of decimal places to track on initialization.
|
| 118 |
+
|
| 119 |
+
>>> spec = Spectrum(3)
|
| 120 |
+
|
| 121 |
+
*x*, *y* pairs may be added using the ``add_value`` method
|
| 122 |
+
|
| 123 |
+
>>> spec.add_value(55.67839, 100)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
When the spectrum has been manipulated to the user's satisfaction, it may be easily converted to
|
| 127 |
+
``[[x values], [y values]`` format using the ``trim()`` method.
|
| 128 |
+
|
| 129 |
+
>>> spec.trim()
|
| 130 |
+
[[55.678], [100]]
|
| 131 |
+
|
| 132 |
+
The incoming x value will be compared to the current x list for equivalent x values. If a matching x value is
|
| 133 |
+
found, the y value is added to the existing value.
|
| 134 |
+
|
| 135 |
+
>>> spec.add_value(55.67799, 100) # equivalent to 55.678
|
| 136 |
+
>>> spec.trim()
|
| 137 |
+
[[55.678], [200]]
|
| 138 |
+
>>> spec.add_value(55.67744, 99) # equivalent to 55.677
|
| 139 |
+
>>> spec.trim()
|
| 140 |
+
[[55.677, 55.678], [99, 200]]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
**y-value manipulation**
|
| 144 |
+
|
| 145 |
+
The y values may be manipulated in a variety of ways.
|
| 146 |
+
|
| 147 |
+
- The ``normalize()`` method will normalize the y values in the instance to the specified value.
|
| 148 |
+
- The ``threshold()`` method will drop y values below a certain value (either relative or absolute).
|
| 149 |
+
- The ``keep_top_n()`` method keeps the top n peaks.
|
| 150 |
+
- The ``consolidate()`` method groups values together using a weighted average algorithm to keep the lowest
|
| 151 |
+
y value above a given threshold but still retain the information in the spectrum.
|
| 152 |
+
|
| 153 |
+
**spectrum constraint methods**
|
| 154 |
+
|
| 155 |
+
- Values below a certain x value may be dropped by calling the ``drop_below()`` method.
|
| 156 |
+
- Values above a certain x value may be dropped by calling the ``drop_above()`` method.
|
| 157 |
+
"""
|
| 158 |
+
self.x = []
|
| 159 |
+
self.y = []
|
| 160 |
+
self.decpl = decpl
|
| 161 |
+
self.empty = empty
|
| 162 |
+
self.filler = filler
|
| 163 |
+
|
| 164 |
+
if empty is False and any([val is None for val in [start, end]]):
|
| 165 |
+
raise ValueError(f'A start and end value must be specified for a filled '
|
| 166 |
+
f'{self.__class__.__name__} instance. ')
|
| 167 |
+
|
| 168 |
+
# set start and end values for the spectrum
|
| 169 |
+
if start is not None:
|
| 170 |
+
self._start = start
|
| 171 |
+
if end is not None:
|
| 172 |
+
self._end = end
|
| 173 |
+
|
| 174 |
+
if self.empty is False:
|
| 175 |
+
self.x, self.y = full_spectrum_list( # m/z and intensity lists
|
| 176 |
+
self.start,
|
| 177 |
+
self.end,
|
| 178 |
+
decpl=decpl,
|
| 179 |
+
filler=filler,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
if specin is not None:
|
| 183 |
+
self.add_spectrum(specin[0], specin[1])
|
| 184 |
+
|
| 185 |
+
def __str__(self):
|
| 186 |
+
return f'Full spectrum from {self.start} to {self.end} keeping {self.decpl} decimal places'
|
| 187 |
+
|
| 188 |
+
def __repr__(self):
|
| 189 |
+
return f'{self.__class__.__name__}({self.start}, {self.end}, {self.decpl})'
|
| 190 |
+
|
| 191 |
+
def __getinitargs__(self):
|
| 192 |
+
return (
|
| 193 |
+
self.decpl,
|
| 194 |
+
self.start,
|
| 195 |
+
self.end,
|
| 196 |
+
self.empty,
|
| 197 |
+
self.filler,
|
| 198 |
+
[self.x, self.y]
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
def __reduce__(self):
|
| 202 |
+
return (
|
| 203 |
+
self.__class__,
|
| 204 |
+
self.__getinitargs__()
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
def __copy__(self):
|
| 208 |
+
return Spectrum(
|
| 209 |
+
*self.__getinitargs__()
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
def __deepcopy__(self, memodict={}):
|
| 213 |
+
return self.__copy__()
|
| 214 |
+
|
| 215 |
+
def __len__(self):
|
| 216 |
+
return len(self.x)
|
| 217 |
+
|
| 218 |
+
def __getitem__(self, ind):
|
| 219 |
+
"""
|
| 220 |
+
if supplied index is an integer, return the x and y value of that index in the list
|
| 221 |
+
if a float, return the intensity of that x value
|
| 222 |
+
"""
|
| 223 |
+
if type(ind) is int:
|
| 224 |
+
return [self.x[ind], self.y[ind]]
|
| 225 |
+
elif type(ind) is float: # returns the intensity value of the specified m/z
|
| 226 |
+
if ind < self.start or ind > self.end:
|
| 227 |
+
raise IndexError(
|
| 228 |
+
'The supplied float %f is outside of the m/z range of this Spectrum instance (%.3f -%.3f)' % (
|
| 229 |
+
ind, self.start, self.end))
|
| 230 |
+
return self.y[self.index(ind)]
|
| 231 |
+
|
| 232 |
+
def __add__(self, x):
|
| 233 |
+
"""
|
| 234 |
+
Since addition to this class requires generating a complete copy of the class then addition,
|
| 235 |
+
using the built in addition methods is recommended
|
| 236 |
+
e.g. to add a single value use .addvalue()
|
| 237 |
+
to add a spectrum use .addspectrum()
|
| 238 |
+
"""
|
| 239 |
+
kwargs = {
|
| 240 |
+
'empty': self.empty,
|
| 241 |
+
'filler': self.filler,
|
| 242 |
+
}
|
| 243 |
+
if isinstance(x, self.__class__) is True: # if it is another Spectrum instance
|
| 244 |
+
if x.decpl != self.decpl:
|
| 245 |
+
raise ValueError(
|
| 246 |
+
'The decimal places of the two spectra to be added are not equal. Addition is not supported')
|
| 247 |
+
newstart = min(self.start, x.start) # find new start m/z
|
| 248 |
+
newend = max(self.end, x.end) # find new end m/z
|
| 249 |
+
tempspec = Spectrum( # temporary instance with self as specin
|
| 250 |
+
self.decpl,
|
| 251 |
+
start=newstart,
|
| 252 |
+
end=newend,
|
| 253 |
+
specin=[self.x, self.y],
|
| 254 |
+
**kwargs,
|
| 255 |
+
)
|
| 256 |
+
tempspec.add_spectrum(x.x, x.y) # add incoming spectrum
|
| 257 |
+
return tempspec
|
| 258 |
+
elif type(x) is int: # add this integer to every m/z
|
| 259 |
+
tempspec = Spectrum(
|
| 260 |
+
self.decpl,
|
| 261 |
+
start=self.start,
|
| 262 |
+
end=self.end,
|
| 263 |
+
**kwargs
|
| 264 |
+
)
|
| 265 |
+
y = np.asarray(self.y)
|
| 266 |
+
y += x
|
| 267 |
+
tempspec.y = y.tolist()
|
| 268 |
+
return tempspec
|
| 269 |
+
elif len(x) == 2 and len(x[0]) == len(x[1]): # if it is a list of paired lists (another spectrum)
|
| 270 |
+
tempspec = Spectrum(
|
| 271 |
+
self.decpl,
|
| 272 |
+
start=self.start,
|
| 273 |
+
end=self.end,
|
| 274 |
+
**kwargs,
|
| 275 |
+
)
|
| 276 |
+
tempspec.y = list(self.y)
|
| 277 |
+
tempspec.add_spectrum(x[0], x[1])
|
| 278 |
+
return tempspec
|
| 279 |
+
else:
|
| 280 |
+
return 'Addition of %s to the Spectrum class is unsupported' % str(x)
|
| 281 |
+
|
| 282 |
+
def __sub__(self, x):
|
| 283 |
+
kwargs = {
|
| 284 |
+
'empty': self.empty,
|
| 285 |
+
'filler': self.filler,
|
| 286 |
+
}
|
| 287 |
+
if isinstance(x, self.__class__) is True: # if it is another Spectrum instance
|
| 288 |
+
if x.decpl != self.decpl:
|
| 289 |
+
raise ValueError(
|
| 290 |
+
'The decimal places of the two spectra to be added are not equal. Subtraction is not supported')
|
| 291 |
+
newstart = min(self.start, x.start) # find new start m/z
|
| 292 |
+
newend = max(self.end, x.end) # find new end m/z
|
| 293 |
+
tempspec = Spectrum(
|
| 294 |
+
self.decpl,
|
| 295 |
+
start=newstart,
|
| 296 |
+
end=newend,
|
| 297 |
+
specin=[self.x, self.y],
|
| 298 |
+
empty=self.empty,
|
| 299 |
+
filler=self.filler
|
| 300 |
+
) # temporary instance
|
| 301 |
+
tempspec.add_spectrum(x.x, x.y, True) # subtract incoming spectrum
|
| 302 |
+
return tempspec
|
| 303 |
+
elif type(x) is int: # add this integer to every m/z
|
| 304 |
+
tempspec = Spectrum(
|
| 305 |
+
self.decpl,
|
| 306 |
+
start=self.start,
|
| 307 |
+
end=self.end,
|
| 308 |
+
**kwargs
|
| 309 |
+
)
|
| 310 |
+
y = np.asarray(self.y)
|
| 311 |
+
y -= x
|
| 312 |
+
tempspec.y = y.tolist()
|
| 313 |
+
return tempspec
|
| 314 |
+
elif len(x) == 2 and len(x[0]) == len(x[1]): # if it is a list of paired lists (another spectrum)
|
| 315 |
+
tempspec = Spectrum(
|
| 316 |
+
self.decpl,
|
| 317 |
+
start=self.start,
|
| 318 |
+
end=self.end,
|
| 319 |
+
**kwargs
|
| 320 |
+
)
|
| 321 |
+
tempspec.y = list(self.y)
|
| 322 |
+
tempspec.add_spectrum(x[0], x[1], True) # subtract the incoming spectrum
|
| 323 |
+
return tempspec
|
| 324 |
+
else:
|
| 325 |
+
return 'Subtraction of %s from the Spectrum class is unsupported' % str(x)
|
| 326 |
+
|
| 327 |
+
def __mul__(self, x):
|
| 328 |
+
raise AttributeError('Multiplication of the Spectrum class is unsupported')
|
| 329 |
+
|
| 330 |
+
def __truediv__(self, x):
|
| 331 |
+
raise AttributeError('Division of the Spectrum class is unsupported')
|
| 332 |
+
|
| 333 |
+
def __pow__(self, x):
|
| 334 |
+
raise AttributeError('Raising a Spectrum instance to a power is unsupported.\nAlso... really?!')
|
| 335 |
+
|
| 336 |
+
@property
|
| 337 |
+
def start(self):
|
| 338 |
+
"""The start value for the spectrum object"""
|
| 339 |
+
return self._start
|
| 340 |
+
|
| 341 |
+
@start.setter
|
| 342 |
+
def start(self, value):
|
| 343 |
+
if value is None:
|
| 344 |
+
value = -np.inf
|
| 345 |
+
value = round(value, self.decpl)
|
| 346 |
+
if value > self._start: # if trimming is required
|
| 347 |
+
index = self.index(value) # find index
|
| 348 |
+
del self.x[:index] # trim spectra
|
| 349 |
+
del self.y[:index]
|
| 350 |
+
self._start = value
|
| 351 |
+
|
| 352 |
+
@start.deleter
|
| 353 |
+
def start(self):
|
| 354 |
+
self._start = -np.inf
|
| 355 |
+
|
| 356 |
+
@property
|
| 357 |
+
def end(self):
|
| 358 |
+
return self._end
|
| 359 |
+
|
| 360 |
+
@end.setter
|
| 361 |
+
def end(self, value):
|
| 362 |
+
if value is None:
|
| 363 |
+
value = np.inf
|
| 364 |
+
value = round(value, self.decpl)
|
| 365 |
+
if value < self._end:
|
| 366 |
+
index = self.index(value) # find index
|
| 367 |
+
self.x = self.x[:index] # trim lists
|
| 368 |
+
self.y = self.y[:index]
|
| 369 |
+
self._end = value
|
| 370 |
+
|
| 371 |
+
@end.deleter
|
| 372 |
+
def end(self):
|
| 373 |
+
self._end = np.inf
|
| 374 |
+
|
| 375 |
+
@property
|
| 376 |
+
def charge(self):
|
| 377 |
+
"""Charge for the spectrum (in mass spectrometry, the x values are mass over charge)"""
|
| 378 |
+
return self._charge
|
| 379 |
+
|
| 380 |
+
@charge.setter
|
| 381 |
+
def charge(self, charge):
|
| 382 |
+
if charge == self._charge: # if already set, ignore
|
| 383 |
+
return
|
| 384 |
+
try: # if numpy array, cheat
|
| 385 |
+
self.x /= charge
|
| 386 |
+
except TypeError: # otherwise iterate over list
|
| 387 |
+
for ind, val in enumerate(self.x):
|
| 388 |
+
self.x[ind] = val / (charge / self._charge)
|
| 389 |
+
# set new bounds
|
| 390 |
+
self.start /= charge
|
| 391 |
+
self.end /= charge
|
| 392 |
+
self._charge = charge
|
| 393 |
+
|
| 394 |
+
@charge.deleter
|
| 395 |
+
def charge(self):
|
| 396 |
+
setattr(self, 'charge', 1)
|
| 397 |
+
|
| 398 |
+
def add_element(self, masses, abunds):
|
| 399 |
+
"""
|
| 400 |
+
Adds the masses and abundances of an element to the current spectrum object.
|
| 401 |
+
This is more efficient than creating a new spectrum object every time an
|
| 402 |
+
element is added.
|
| 403 |
+
|
| 404 |
+
:param list masses: List of masses (*x* values).
|
| 405 |
+
:param list abunds: abundances (*y* values, paired with ``masses``)
|
| 406 |
+
|
| 407 |
+
For example, to add a single atom of carbon to the ``Spectrum`` object
|
| 408 |
+
|
| 409 |
+
>>> Spectrum.add_element(
|
| 410 |
+
[12.0, 13.0033548378],
|
| 411 |
+
[0.9893, 0.0107]
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
**Note**
|
| 415 |
+
|
| 416 |
+
This function will encounter an error if there are None values in the y list.
|
| 417 |
+
If you intend to use this function, set the *filler* keyword argument to be
|
| 418 |
+
some value that is not None (i.e. ``0.``).
|
| 419 |
+
|
| 420 |
+
"""
|
| 421 |
+
if len(masses) != len(abunds):
|
| 422 |
+
raise ValueError(
|
| 423 |
+
f'The dimensions of the supplied lists are not equal ({len(masses)} != {len(abunds)})')
|
| 424 |
+
if self.filler is None and self.count_none() > 0:
|
| 425 |
+
raise ValueError('add_element cannot operate on a y list populated with None values')
|
| 426 |
+
|
| 427 |
+
# create matricies of new x and y values
|
| 428 |
+
newx = np.asarray(self.x) + np.asarray(
|
| 429 |
+
[[val] for val in masses] # values must be boxed for appropriate combination
|
| 430 |
+
)
|
| 431 |
+
newy = np.asarray(self.y) * np.asarray(
|
| 432 |
+
[[val] for val in abunds]
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
## does not call for a new disposable object
|
| 436 |
+
"""
|
| 437 |
+
extending the spectrum object then dropping is very fast,
|
| 438 |
+
but requires subtracting the original spectrum before dropping
|
| 439 |
+
slicing, deepcopy, list(), building the subtraction into the boxxed lists, and switching the array calls
|
| 440 |
+
are all slower than generating a temporary Spectrum object
|
| 441 |
+
"""
|
| 442 |
+
# self.newend(max(masses) + max(self.x)) # define new end point for Spectrum object
|
| 443 |
+
# for i in range(newx.shape[0]): # add calculated masses and intensities to object
|
| 444 |
+
# if i == 0:
|
| 445 |
+
# self.addspectrum(newx[i],newy[i],True) # subtract original spectrum
|
| 446 |
+
# continue
|
| 447 |
+
# self.addspectrum(newx[i],newy[i])
|
| 448 |
+
# self.addspectrum(oldx,oldy,True) # subtract old spectrum
|
| 449 |
+
# self.dropbelow(min(masses) + min(self.x)) # drop values below new start point
|
| 450 |
+
|
| 451 |
+
tempspec = Spectrum(
|
| 452 |
+
self.decpl,
|
| 453 |
+
start=min(masses) + self.start - 10 ** -self.decpl,
|
| 454 |
+
end=max(masses) + self.end + 10 ** -self.decpl,
|
| 455 |
+
empty=self.empty,
|
| 456 |
+
filler=self.filler,
|
| 457 |
+
)
|
| 458 |
+
for x, y in zip(newx, newy):
|
| 459 |
+
tempspec.add_spectrum(x, y)
|
| 460 |
+
|
| 461 |
+
# for i in range(newx.shape[0]):
|
| 462 |
+
# tempspec.addspectrum(newx[i], newy[i])
|
| 463 |
+
self.x = tempspec.x # redefine the x and y lists
|
| 464 |
+
self.y = tempspec.y
|
| 465 |
+
self._start = min(masses) + self.start - 10 ** -self.decpl
|
| 466 |
+
self._end = max(masses) + self.end + 10 ** -self.decpl
|
| 467 |
+
|
| 468 |
+
def add_value(self, xval, yval, subtract=False):
|
| 469 |
+
"""
|
| 470 |
+
Adds an intensity value to the x value specified.
|
| 471 |
+
|
| 472 |
+
:param float xval: The *x* value. This value will be rounded to the decimal place specified on calling this class.
|
| 473 |
+
:param float yval: The *y* value to add to the *y* list.
|
| 474 |
+
:param bool subtract: Make this ``True`` if you wish to subtract the *y* value from the current *y* value at
|
| 475 |
+
the specified x.
|
| 476 |
+
|
| 477 |
+
**Examples**
|
| 478 |
+
|
| 479 |
+
>>> spec = Spectrum(3)
|
| 480 |
+
>>> spec.trim()
|
| 481 |
+
[[], []]
|
| 482 |
+
|
| 483 |
+
>>> spec.add_value(673.9082342357,100)
|
| 484 |
+
>>> spec.trim()
|
| 485 |
+
[[673.908], [100]]
|
| 486 |
+
|
| 487 |
+
>>> spec.add_value(1523.25375621,200)
|
| 488 |
+
>>> spec.add_value(50.89123,300)
|
| 489 |
+
>>> spec.trim()
|
| 490 |
+
[[50.891, 673.908, 1523.254], [300, 100, 200]]
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
**Note**
|
| 494 |
+
|
| 495 |
+
If the x value is not within the x bounds specified by the keyword
|
| 496 |
+
arguments *start* and *end*, the supplied y value will not be added
|
| 497 |
+
to the current spectrum object.
|
| 498 |
+
|
| 499 |
+
"""
|
| 500 |
+
if yval is not None: # if handed an actual value
|
| 501 |
+
try: # try indexing
|
| 502 |
+
index = self.index(xval)
|
| 503 |
+
if subtract is True: # set sign based on input
|
| 504 |
+
sign = -1
|
| 505 |
+
else:
|
| 506 |
+
sign = 1
|
| 507 |
+
if self.empty is False: # if x list filled
|
| 508 |
+
try:
|
| 509 |
+
self.y[index] += yval * sign # try to add value
|
| 510 |
+
except TypeError:
|
| 511 |
+
self.y[index] = yval * sign # if None, then set to value
|
| 512 |
+
else:
|
| 513 |
+
if len(self.x) == 0 and index == 0:
|
| 514 |
+
self.x.insert(index, round(xval, self.decpl))
|
| 515 |
+
self.y.insert(index, yval * sign)
|
| 516 |
+
elif index == len(self.x): # if at end of list
|
| 517 |
+
self.x.append(round(xval, self.decpl))
|
| 518 |
+
self.y.append(yval)
|
| 519 |
+
elif self.x[index] != round(xval, self.decpl): # if the index does not equal the value
|
| 520 |
+
self.x.insert(index, round(xval, self.decpl)) # insert x value at specified index
|
| 521 |
+
self.y.insert(index, yval * sign) # insert the y value
|
| 522 |
+
else:
|
| 523 |
+
try: # otherwise add
|
| 524 |
+
self.y[index] += yval * sign
|
| 525 |
+
except TypeError: # or set to value if None
|
| 526 |
+
self.y[index] = yval * sign
|
| 527 |
+
except ValueError: # if index is not in spectrum
|
| 528 |
+
pass # do nothing (the value will not be added to the spectrum)
|
| 529 |
+
|
| 530 |
+
def add_spectrum(self, x, y, subtract=False):
|
| 531 |
+
"""
|
| 532 |
+
Adds an entire x and y list to the spectrum object.
|
| 533 |
+
This avoids having to call ``Spectrum.addvalue()`` in loop form
|
| 534 |
+
for a list of values.
|
| 535 |
+
|
| 536 |
+
:param list x: List of x values. These may be unsorted, but are assumed to be paired with the supplied *y* list.
|
| 537 |
+
:param list y: List of y values, paired with *x*.
|
| 538 |
+
:param bool subtract: Whether or not to subtract the y intensities from the current Spectrum object.
|
| 539 |
+
"""
|
| 540 |
+
if len(x) != len(y):
|
| 541 |
+
raise ValueError('The add_spectrum() method only supports two lists of the same dimension')
|
| 542 |
+
for ind, mz in enumerate(x):
|
| 543 |
+
if y[ind] != self.filler: # drops filler values at this point
|
| 544 |
+
self.add_value(mz, y[ind], subtract)
|
| 545 |
+
|
| 546 |
+
def check_none(self):
|
| 547 |
+
"""counts the number of not-None values in the current *y* list (for debugging)"""
|
| 548 |
+
return len(self.y) - self.count_none()
|
| 549 |
+
|
| 550 |
+
def count_none(self):
|
| 551 |
+
"""counts the number of None values in the current *y* list (for debugging)"""
|
| 552 |
+
return self.y.count(None)
|
| 553 |
+
|
| 554 |
+
def consolidate(self, threshold, within, method='abs'):
|
| 555 |
+
"""
|
| 556 |
+
A method of reducing the number of values in the spectrum object by consolidating y values below the specified
|
| 557 |
+
threshold with nearby values. The method of combination is a weighted average. The intensities of adjacent
|
| 558 |
+
values are combined until the threshold is passed or until no adjacent values within the specified x delta
|
| 559 |
+
can be found.
|
| 560 |
+
|
| 561 |
+
:param float threshold: The threshold value, below which the value will be consolidated into adjacent peaks.
|
| 562 |
+
:param float within: The x delta to look within when consolidating peaks.
|
| 563 |
+
:param 'abs' or 'rel' method: Whether to use an absolute or relative threshold.
|
| 564 |
+
:return:
|
| 565 |
+
"""
|
| 566 |
+
def adjacent(index):
|
| 567 |
+
"""
|
| 568 |
+
locates the index of the closest x value to the provided index
|
| 569 |
+
(only returns an index if there is a value within the given delta
|
| 570 |
+
"""
|
| 571 |
+
i = None
|
| 572 |
+
if index != 0 and self.x[index] - self.x[index - 1] <= within: # if previous index is nearer than within
|
| 573 |
+
if self.y[index - 1] != 0: # if the peak has intensity
|
| 574 |
+
i = index - 1
|
| 575 |
+
delta = self.x[index] - self.x[index - 1]
|
| 576 |
+
if index != len(self.x) - 1 and self.x[index + 1] - self.x[
|
| 577 |
+
index] <= within: # if next index is nearer than within
|
| 578 |
+
if self.y[index + 1] != 0: # if the peak has intensity
|
| 579 |
+
if i is not None: # if i is already defined
|
| 580 |
+
if self.x[index + 1] - self.x[index] <= within: # if the greater than is closer
|
| 581 |
+
i = index + 1
|
| 582 |
+
else:
|
| 583 |
+
i = index + 1
|
| 584 |
+
return i
|
| 585 |
+
|
| 586 |
+
for ind in range(len(self.y)):
|
| 587 |
+
if self.y[ind] < threshold and self.y[ind] != 0.:
|
| 588 |
+
cur = ind
|
| 589 |
+
closest = adjacent(cur) # looks for adjacent peaks within the delta
|
| 590 |
+
while self.y[cur] < threshold and closest is not None:
|
| 591 |
+
self.add_value( # subtract the current value
|
| 592 |
+
self.x[cur],
|
| 593 |
+
self.y[cur],
|
| 594 |
+
True
|
| 595 |
+
)
|
| 596 |
+
self.add_value( # subtract the adjacent value
|
| 597 |
+
self.x[closest],
|
| 598 |
+
self.y[closest],
|
| 599 |
+
True
|
| 600 |
+
)
|
| 601 |
+
wx, wy = weighted_average( # weighted average of removed values
|
| 602 |
+
[self.x[cur], self.x[closest]],
|
| 603 |
+
[self.x[cur], self.y[closest]]
|
| 604 |
+
)
|
| 605 |
+
self.add_value(wx, wy) # add the weighted average to the spectrum
|
| 606 |
+
cur = self.index(wx) # set current index to that of the new value
|
| 607 |
+
# cur = closest # set current index to the one being tested
|
| 608 |
+
closest = adjacent(cur)
|
| 609 |
+
self.threshold(threshold, method) # drop any peaks that could not be combined
|
| 610 |
+
|
| 611 |
+
def cp(self):
|
| 612 |
+
"""returns a list (clone) of the spectrum"""
|
| 613 |
+
return [list(self.x), list(self.y)]
|
| 614 |
+
|
| 615 |
+
def fill_with_zeros(self, value=0.):
|
| 616 |
+
"""
|
| 617 |
+
Replaces any ``None`` values in the *y* list with the specified value.
|
| 618 |
+
|
| 619 |
+
:param value: value to replace ``None`` with.
|
| 620 |
+
:return: new y list
|
| 621 |
+
:rtype: list
|
| 622 |
+
"""
|
| 623 |
+
for ind, inten in enumerate(self.y):
|
| 624 |
+
if inten is None:
|
| 625 |
+
self.y[ind] = value
|
| 626 |
+
return self.y
|
| 627 |
+
|
| 628 |
+
def index(self, xval):
|
| 629 |
+
"""
|
| 630 |
+
Locates the index of the specified x value in the object's x list.
|
| 631 |
+
|
| 632 |
+
:param float xval: The x value to locate in the list.
|
| 633 |
+
:return: The integer index for the x value in the x list.
|
| 634 |
+
:rtype: int
|
| 635 |
+
|
| 636 |
+
**Notes**
|
| 637 |
+
|
| 638 |
+
If the *empty* keyword argument is True, the index will be located using the bisect module. If the x value is
|
| 639 |
+
not in the current x list, the appropriate insertion index is returned. If the *empty* keyword argument is
|
| 640 |
+
False, the index will be calculated based on the ``start`` and ``decpl`` values of the object (this is more
|
| 641 |
+
computationally efficient than bisection).
|
| 642 |
+
"""
|
| 643 |
+
if xval > self.end or xval < self.start:
|
| 644 |
+
raise ValueError(
|
| 645 |
+
f'The x value {xval} is outside of the x-list range of this {self.__class__.__name__} instance '
|
| 646 |
+
f'({self.start}, {self.end}).'
|
| 647 |
+
)
|
| 648 |
+
if self.empty is True: # if spectrum is unfilled, searching is required
|
| 649 |
+
return bl(self.x, round(xval, self.decpl))
|
| 650 |
+
else: # otherwise, calculation of the index is more efficient
|
| 651 |
+
return int(
|
| 652 |
+
round( # round after multiplication
|
| 653 |
+
(xval - self.start) * (10 ** self.decpl) # calculate index location
|
| 654 |
+
)
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
+
def nearest_x_index(self, xval):
|
| 658 |
+
"""
|
| 659 |
+
Finds the index of the closest x value to the one provided. This method differs from `index()` in that this
|
| 660 |
+
finds the closest value and index finds the insertion point to maintain an ordered list.
|
| 661 |
+
|
| 662 |
+
:param xval: x value to find
|
| 663 |
+
:return: index of nearest value
|
| 664 |
+
"""
|
| 665 |
+
if xval < self.start:
|
| 666 |
+
return 0
|
| 667 |
+
if xval > self.end:
|
| 668 |
+
return len(self.x) - 1
|
| 669 |
+
if self.empty is False:
|
| 670 |
+
return self.index(xval)
|
| 671 |
+
index = self.index(xval)
|
| 672 |
+
if index == len(self.x):
|
| 673 |
+
return len(self.x) - 1
|
| 674 |
+
potentials = [index]
|
| 675 |
+
if index + 1 != len(self.x):
|
| 676 |
+
potentials.append(index + 1)
|
| 677 |
+
if index != 0:
|
| 678 |
+
potentials.append(index - 1)
|
| 679 |
+
return min(
|
| 680 |
+
potentials,
|
| 681 |
+
key=lambda x: abs(xval - self.x[x])
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
def keep_top_n(self, n=5000):
|
| 685 |
+
"""
|
| 686 |
+
Keeps the top n peaks and sets the intensity of those below that value to be zero.
|
| 687 |
+
|
| 688 |
+
:param int n: The number of values to keep in the list.
|
| 689 |
+
|
| 690 |
+
**Notes**
|
| 691 |
+
|
| 692 |
+
If there is more than one y value equal to the nth lowest value, then all of those values will be retained.
|
| 693 |
+
|
| 694 |
+
"""
|
| 695 |
+
if n > len(self.x): # do nothing if number is longer than the number of values in the Spectrum object
|
| 696 |
+
return
|
| 697 |
+
self.threshold( # use the threhold method
|
| 698 |
+
sorted( # sort the y list in reverse order
|
| 699 |
+
self.y,
|
| 700 |
+
reverse=True,
|
| 701 |
+
)[n], # use the value at the nth index as the threshold value
|
| 702 |
+
'abs', # apply the absolute threshold
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
def max(self):
|
| 706 |
+
"""
|
| 707 |
+
Locates the maximum intensity in the y list and returns the x and y values of that point.
|
| 708 |
+
|
| 709 |
+
:return: Returns the x and y values of the maximum y value.
|
| 710 |
+
:rtype: tuple of float
|
| 711 |
+
"""
|
| 712 |
+
locs = np.where( # locate index of maximum values
|
| 713 |
+
np.asarray(self.y) == max(self.y)
|
| 714 |
+
)
|
| 715 |
+
if len(locs[0]) > 1: # if there is more than one value equal to the maximum
|
| 716 |
+
out = []
|
| 717 |
+
for i in locs[0]:
|
| 718 |
+
out.append([self.x[i], self.y[i]])
|
| 719 |
+
return out
|
| 720 |
+
return self.x[locs[0][0]], self.y[locs[0][0]]
|
| 721 |
+
|
| 722 |
+
def normalize(self, new_top=100.):
|
| 723 |
+
"""
|
| 724 |
+
Normalizes the y values to the specified value.
|
| 725 |
+
|
| 726 |
+
:param float new_top: The new value for the maximum y value.
|
| 727 |
+
"""
|
| 728 |
+
# old numpy way (probably less efficient)
|
| 729 |
+
# m = max(self.y) # current maximum y value
|
| 730 |
+
# self.y = np.asarray(self.y)
|
| 731 |
+
# self.y /= m
|
| 732 |
+
# self.y *= new_top
|
| 733 |
+
# self.y = self.y.tolist()
|
| 734 |
+
scalar = new_top / max(self.y) # calculate the appropriate scalar
|
| 735 |
+
for ind, inten in enumerate(self.y):
|
| 736 |
+
if inten is not None:
|
| 737 |
+
self.y[ind] *= scalar
|
| 738 |
+
|
| 739 |
+
def shift_x(self, value):
|
| 740 |
+
"""
|
| 741 |
+
Offsets all *x* values by the specified value.
|
| 742 |
+
|
| 743 |
+
:param float value: The amount to offset the x values by.
|
| 744 |
+
"""
|
| 745 |
+
for ind, val in enumerate(self.x):
|
| 746 |
+
self.x[ind] += value
|
| 747 |
+
if self.start != -np.inf:
|
| 748 |
+
self.start += value
|
| 749 |
+
if self.end != np.inf:
|
| 750 |
+
self.end += value
|
| 751 |
+
|
| 752 |
+
def sum(self):
|
| 753 |
+
"""
|
| 754 |
+
Calculates and returns the sum of all y values in the object.
|
| 755 |
+
|
| 756 |
+
:return: The sum of all y values in the y list.
|
| 757 |
+
:rtype: float
|
| 758 |
+
"""
|
| 759 |
+
return sum(
|
| 760 |
+
[y for y in self.y if y is not None]
|
| 761 |
+
)
|
| 762 |
+
|
| 763 |
+
def reset_y(self):
|
| 764 |
+
"""
|
| 765 |
+
Resets the y values in the Spectrum object. This allows reuse of the same Spectrum object without regenerating.
|
| 766 |
+
"""
|
| 767 |
+
self.y = [self.filler for val in self.y]
|
| 768 |
+
|
| 769 |
+
def threshold(self, thresh, method='abs'):
|
| 770 |
+
"""
|
| 771 |
+
Removes all y values below the specified threshold value.
|
| 772 |
+
|
| 773 |
+
:param float thresh: The threshold y value to drop below.
|
| 774 |
+
:param 'abs' or 'rel' method: Whether the specifed *thresh* value is absolute or relative to the maximum y
|
| 775 |
+
value.
|
| 776 |
+
"""
|
| 777 |
+
if method == 'rel': # if relative, calculate relative threshold
|
| 778 |
+
thresh *= max(self.y)
|
| 779 |
+
if self.empty is True: # removes values from the list
|
| 780 |
+
x = []
|
| 781 |
+
y = []
|
| 782 |
+
for ind, inten in enumerate(self.y):
|
| 783 |
+
if inten >= thresh:
|
| 784 |
+
x.append(self.x[ind])
|
| 785 |
+
y.append(inten)
|
| 786 |
+
self.x = x
|
| 787 |
+
self.y = y
|
| 788 |
+
else:
|
| 789 |
+
for ind, inten in enumerate(self.y):
|
| 790 |
+
if inten < thresh:
|
| 791 |
+
self.y[ind] = self.filler # sets the value to the filler value
|
| 792 |
+
|
| 793 |
+
def trim(self, zeros=False, xbounds=None):
|
| 794 |
+
"""
|
| 795 |
+
Trims x and y pairs that have None intensity and returns the trimmed list.
|
| 796 |
+
This is the most efficient way of converting a Spectrum object to an x and y list.
|
| 797 |
+
|
| 798 |
+
:param bool zeros: Specifies whether there should be zeros at the start and end values. This can be used to
|
| 799 |
+
generate continuum spectra across the range [start,end]. If there are non-zero intensity values at the
|
| 800 |
+
start or end point, they will not be affected.
|
| 801 |
+
:param list xbounds: This can specify a subsection of the x and y spectra to trim to. None will return the entire
|
| 802 |
+
contents of the Spectrum object, and specifying ``[x1,x2]]`` will return the x and y lists between
|
| 803 |
+
*x1* and *x2*.
|
| 804 |
+
:return: trimmed spectrum in the form ``[[x values], [y values]]``
|
| 805 |
+
:rtype: list of lists
|
| 806 |
+
"""
|
| 807 |
+
# retrieve boundaries
|
| 808 |
+
if xbounds is None:
|
| 809 |
+
xbounds = [self.start, self.end]
|
| 810 |
+
elif xbounds[0] is None:
|
| 811 |
+
xbounds[0] = self.start
|
| 812 |
+
elif xbounds[1] is None:
|
| 813 |
+
xbounds[1] = self.end
|
| 814 |
+
|
| 815 |
+
xout = []
|
| 816 |
+
yout = []
|
| 817 |
+
|
| 818 |
+
for ind in range(self.index(xbounds[0]), self.index(xbounds[1])): # iterate over slice
|
| 819 |
+
if self.y[ind] is not self.filler:
|
| 820 |
+
xout.append(round(self.x[ind], self.decpl)) # rounded to avoid array floating point weirdness
|
| 821 |
+
yout.append(self.y[ind])
|
| 822 |
+
|
| 823 |
+
if zeros is True: # if zeros was specified, check for and insert values as necessary
|
| 824 |
+
if len(xout) == 0: # if there is no intensity in the spectrum
|
| 825 |
+
xout = [float(self.start), float(self.end)]
|
| 826 |
+
yout = [0., 0.]
|
| 827 |
+
if xout[0] != self.start:
|
| 828 |
+
xout.insert(0, self.start)
|
| 829 |
+
yout.insert(0, 0.)
|
| 830 |
+
if xout[-1] != self.end:
|
| 831 |
+
xout.append(self.end)
|
| 832 |
+
yout.append(0.)
|
| 833 |
+
return [xout, yout]
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def check_indexing(n=1000, dec=3):
|
| 837 |
+
"""
|
| 838 |
+
Validates the indexing functionality of the Spectrum class
|
| 839 |
+
|
| 840 |
+
:param n: number of iterations
|
| 841 |
+
:param dec: decimal place for the Spectrum object
|
| 842 |
+
:return: number of mismatches, details
|
| 843 |
+
"""
|
| 844 |
+
spec = Spectrum(3)
|
| 845 |
+
mismatch = 0
|
| 846 |
+
mml = []
|
| 847 |
+
for i in range(n):
|
| 848 |
+
num = random()
|
| 849 |
+
mz = num * 2000.
|
| 850 |
+
try:
|
| 851 |
+
index = spec.index(mz)
|
| 852 |
+
except ValueError:
|
| 853 |
+
continue
|
| 854 |
+
if round(mz, dec) != round(spec.x[index], dec):
|
| 855 |
+
mismatch += 1
|
| 856 |
+
mml.append([mz, round(mz, dec), round(spec.x[index], dec)])
|
| 857 |
+
return mismatch, mml
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
if __name__ == '__main__':
|
| 861 |
+
pass
|
| 862 |
+
# spec = Spectrum(3)
|
| 863 |
+
# spec = Spectrum(4,start=12.0,end=13.0033548378,specin=[[12.0,13.0033548378],[0.9893, 0.0107]],empty=True,filler=0.)
|
| 864 |
+
# masses = [12.0,13.0033548378]
|
| 865 |
+
# abunds = [0.9893, 0.0107]
|
| 866 |
+
|
| 867 |
+
# spec = Spectrum(10,start=1,end=3,specin=[[1.00782503207,2.0141017778],[0.999885,0.000115]],empty=True,filler=0.)
|
| 868 |
+
# masses = [1.00782503207,2.0141017778]
|
| 869 |
+
# abunds = [0.999885,0.000115]
|
| 870 |
+
|
| 871 |
+
# dec = 4
|
| 872 |
+
# spec = Spectrum(dec,start=1,end=3,specin=[[1.00782503207,2.0141017778],[0.999885,0.000115]],empty=True,filler=0.)
|
| 873 |
+
# masses = [15.99491461956,16.9991317,17.999161]
|
| 874 |
+
# abunds = [0.99757,0.00038,0.00205]
|
| 875 |
+
#
|
| 876 |
+
#
|
| 877 |
+
# print spec
|
| 878 |
+
#
|
| 879 |
+
# thresh = 0.01
|
| 880 |
+
# cons = 3*10**-dec
|
| 881 |
+
#
|
| 882 |
+
# for i in range(3900):
|
| 883 |
+
# sys.stdout.write('\rcarbons: %d' %(i+1))
|
| 884 |
+
# spec.addelement([12.0,13.0033548378],[0.9893, 0.0107])
|
| 885 |
+
# spec.normalize(100.)
|
| 886 |
+
# spec.consolidate(thresh,cons)
|
| 887 |
+
# sys.stdout.write('\n')
|
| 888 |
+
# print 'length of x:', len(spec.x)
|
| 889 |
+
# for i in range(2401):
|
| 890 |
+
# sys.stdout.write('\roxygens: %d' %(i+1))
|
| 891 |
+
# spec.addelement(masses,abunds)
|
| 892 |
+
# spec.normalize(100.)
|
| 893 |
+
# spec.consolidate(thresh,cons)
|
| 894 |
+
# sys.stdout.write('\n')
|
| 895 |
+
# print 'length of x:',len(spec.x)
|
| 896 |
+
#
|
| 897 |
+
# st.printelapsed()
|
| 898 |
+
# st.printprofiles()
|
| 899 |
+
# mismatch,mml = checkindexing(1000,3)
|
| 900 |
+
# print mismatch
|
lib/pythoms/tome.py
ADDED
|
@@ -0,0 +1,1237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tome v02 A compilation of all Lars' python scripts as callable functions
|
| 3 |
+
|
| 4 |
+
IGNORE:
|
| 5 |
+
functions:
|
| 6 |
+
autoresolution (estimates the resolution of a spectrum)
|
| 7 |
+
bindata (bins a list of values)
|
| 8 |
+
binnspectra (bins n mass spectra together into a single mass spectrum)
|
| 9 |
+
bincidspectra (bins mass spectra together based on their collision voltage)
|
| 10 |
+
filepresent (checks for a file or directory in the current working directory)
|
| 11 |
+
find_all (finds all locations of files of a given name in a provided directory)
|
| 12 |
+
linmag (generates a list of values which is linear in magnification)
|
| 13 |
+
linramp (generates a list of values which is linear from start to finish)
|
| 14 |
+
locateinlist (locates a value or the closest value to it in a sorted list)
|
| 15 |
+
lyround (rounds a number given a particular base number)
|
| 16 |
+
mag (calculates and returns the magnification of a given y value relative to the start)
|
| 17 |
+
normalize (normalizes a list to a given value)
|
| 18 |
+
plotms (plots a mass spectrum)
|
| 19 |
+
sigmafwhm (cacluates sigma and fwhm from a resolution and a mass)
|
| 20 |
+
strtolist (converts a string to a list)
|
| 21 |
+
version_input (uses the appropriate user input function depending on the python version)
|
| 22 |
+
|
| 23 |
+
changelog:
|
| 24 |
+
created mzML class and moved many functions to work within that class (removed several functions from Tome)
|
| 25 |
+
added strtolist
|
| 26 |
+
moved classes to separate files
|
| 27 |
+
fullspeclist has been moved to _Spectrum class (there were issues with mutation of the original)
|
| 28 |
+
calcindex has also been moved to _Spectrum class (it is used solely in that class)
|
| 29 |
+
moved colours to _Colour class
|
| 30 |
+
removed automz (now handled in the Molecule class)
|
| 31 |
+
created bincidspectra to bin spectra with the same cid together
|
| 32 |
+
removed loadwb, openpyxlcheck, pullparams (now included in XLSX class)
|
| 33 |
+
generalized filepresent
|
| 34 |
+
removed pwconvert (now included in mzML class)
|
| 35 |
+
completely rewrote resolution
|
| 36 |
+
rewrote resolution again to check multiple portions of the spectrum
|
| 37 |
+
significant change to plotms
|
| 38 |
+
moved alpha to XLSX class
|
| 39 |
+
---v02---
|
| 40 |
+
IGNORE
|
| 41 |
+
"""
|
| 42 |
+
import os
|
| 43 |
+
import sys
|
| 44 |
+
import scipy as sci
|
| 45 |
+
import numpy as np
|
| 46 |
+
from .spectrum import Spectrum
|
| 47 |
+
from bisect import bisect_left, bisect_right
|
| 48 |
+
from .colour import Colour
|
| 49 |
+
from .molecule import IPMolecule
|
| 50 |
+
import pylab as pl
|
| 51 |
+
|
| 52 |
+
# ----------------------------------------------------------
|
| 53 |
+
# -------------------FUNCTION DEFINITIONS-------------------
|
| 54 |
+
# ----------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def resolution(x, y, index=None, threshold=5):
|
| 58 |
+
"""
|
| 59 |
+
Finds the resolution and full width at half max of a spectrum
|
| 60 |
+
|
| 61 |
+
:param x: list of mz values
|
| 62 |
+
:param y: corresponding list of intensity values
|
| 63 |
+
:param index: index of maximum intensity (optional; used if the resolution of a specific peak is desired)
|
| 64 |
+
:param threshold: signal to noise threshold required to output a resolution
|
| 65 |
+
:return: resolution
|
| 66 |
+
"""
|
| 67 |
+
y = sci.asarray(y) # convert to array for efficiency
|
| 68 |
+
if index is None: # find index and value of maximum
|
| 69 |
+
maxy = max(y)
|
| 70 |
+
index = sci.where(y == maxy)[0][0]
|
| 71 |
+
else:
|
| 72 |
+
maxy = y[index]
|
| 73 |
+
# if intensity to average is below this threshold (rough estimate of signal/noise)
|
| 74 |
+
if maxy / (sum(y) / len(y)) < threshold:
|
| 75 |
+
return None
|
| 76 |
+
halfmax = maxy / 2
|
| 77 |
+
indleft = int(index) - 1 # generate index counters for left and right walking
|
| 78 |
+
indright = int(index) + 1
|
| 79 |
+
while y[indleft] > halfmax: # while intensity is still above halfmax
|
| 80 |
+
indleft -= 1
|
| 81 |
+
while y[indright] > halfmax:
|
| 82 |
+
indright += 1
|
| 83 |
+
return x[index] / (x[indright] - x[indleft]) # return resolution (mz over full width at half max)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def autoresolution(x, y, n=10, v=True):
|
| 87 |
+
"""
|
| 88 |
+
Attempts to determine the resolution of a provided spectrum by finding n pseudo-random
|
| 89 |
+
samples, then finding a peak in each of those samples to determine the resolution.
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
**Parameters**
|
| 93 |
+
|
| 94 |
+
x: *list*
|
| 95 |
+
List of x values (1D list)
|
| 96 |
+
|
| 97 |
+
y: *list*
|
| 98 |
+
List of y values (1D list, must be the same length as *x*)
|
| 99 |
+
|
| 100 |
+
n: *int*, optional
|
| 101 |
+
Number of sections to check in the supplied spectrum
|
| 102 |
+
|
| 103 |
+
v: *Bool*, optional
|
| 104 |
+
Verbose toggle
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
**Returns**
|
| 108 |
+
|
| 109 |
+
resolution: *float*
|
| 110 |
+
The average resolution value determined by the function
|
| 111 |
+
|
| 112 |
+
"""
|
| 113 |
+
if len(x) == 0 or len(y) == 0:
|
| 114 |
+
raise ValueError('the function has been handed an empty list')
|
| 115 |
+
|
| 116 |
+
if v is True:
|
| 117 |
+
sys.stdout.write('\rEstimating resolution of the spectrum')
|
| 118 |
+
|
| 119 |
+
# find some peaks in the spectrum
|
| 120 |
+
split = int(len(y) / n)
|
| 121 |
+
start = 0
|
| 122 |
+
end = start + split
|
| 123 |
+
splity = []
|
| 124 |
+
for i in range(n):
|
| 125 |
+
splity.append(sci.asarray(y[start:end]))
|
| 126 |
+
start += split
|
| 127 |
+
end += split
|
| 128 |
+
inds = []
|
| 129 |
+
for ind, section in enumerate(splity):
|
| 130 |
+
maxy = max(section)
|
| 131 |
+
if maxy == max(section[1:-1]): # if max is not at the edge of the spectrum
|
| 132 |
+
inds.append(sci.where(section == maxy)[0][0] + split * ind)
|
| 133 |
+
|
| 134 |
+
res = []
|
| 135 |
+
for ind in inds: # for each of those peaks
|
| 136 |
+
res.append(resolution(x, y, ind))
|
| 137 |
+
res = [y for y in res if y is not None] # removes None values (below S/N)
|
| 138 |
+
res = sum(res) / len(res) # calculate average
|
| 139 |
+
if v is True:
|
| 140 |
+
sys.stdout.write(': %.1f\n' % res)
|
| 141 |
+
return res # return average
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def bindata(n, lst, v=1):
|
| 145 |
+
"""
|
| 146 |
+
Bins a list of values into bins of size *n*.
|
| 147 |
+
|
| 148 |
+
**Parameters**
|
| 149 |
+
|
| 150 |
+
n: *int*
|
| 151 |
+
Number of values to bin together. e.g. ``n = 4`` would bin the first four values into a single value, then the next 4, etc.
|
| 152 |
+
|
| 153 |
+
lst: *list*
|
| 154 |
+
List of values to bin.
|
| 155 |
+
|
| 156 |
+
v: *int* or *float*, optional
|
| 157 |
+
Bin scalar. The calculated bin values will be divided by this value. e.g. if ``n = v`` the output values will be an average of each bin.
|
| 158 |
+
|
| 159 |
+
**Returns**
|
| 160 |
+
|
| 161 |
+
binned list: *list*
|
| 162 |
+
A list of binned values.
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
**Notes**
|
| 166 |
+
|
| 167 |
+
- If the list is not divisible by `n`, the final bin will not be included in the output list. (The last values will be discarded.)
|
| 168 |
+
|
| 169 |
+
"""
|
| 170 |
+
out = []
|
| 171 |
+
delta = 0
|
| 172 |
+
ttemp = 0
|
| 173 |
+
for ind, val in enumerate(lst):
|
| 174 |
+
delta += 1
|
| 175 |
+
ttemp += val # add current value to growing sum
|
| 176 |
+
if delta == n: # critical number is reached
|
| 177 |
+
out.append(ttemp / float(v)) # append sum to list
|
| 178 |
+
delta = 0 # reset critical count and sum
|
| 179 |
+
ttemp = 0
|
| 180 |
+
return out
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def binnspectra(lst, n, dec=3, start=50., end=2000.):
|
| 184 |
+
"""
|
| 185 |
+
Sums n spectra together.
|
| 186 |
+
|
| 187 |
+
**Parameters**
|
| 188 |
+
|
| 189 |
+
lst: *list*
|
| 190 |
+
A list of paired lists of the form ``[ [[x1,x2,...,xn],[y1,y2,...,yn]] , [[],[]] ,...]``
|
| 191 |
+
where each index of the parent list is one paired spectrum of x and y values.
|
| 192 |
+
The x values of one index do not have to be the same. The spectra will be combined based on the x value rounded to the nearest 10^-`dec`.
|
| 193 |
+
|
| 194 |
+
n: *int*
|
| 195 |
+
The number of adjacent spectra to bin together. e.g. ``n = 4`` would bin the first four spectra into a single spectrum, then the next 4, etc.
|
| 196 |
+
|
| 197 |
+
dec: *int*
|
| 198 |
+
The decimal place to track the x values to. e.g. ``dec = 3`` would track x values to the nearest 0.001 (10^-3)
|
| 199 |
+
|
| 200 |
+
start: *float*, optional
|
| 201 |
+
The minimum x value to track in the summed spectra.
|
| 202 |
+
|
| 203 |
+
end: *float*, optional
|
| 204 |
+
The maximum x value to track in the summed spectra.
|
| 205 |
+
|
| 206 |
+
**Returns**
|
| 207 |
+
|
| 208 |
+
binned spectrum list: *list*
|
| 209 |
+
A list of paired lists (similar to *lst*) where each index is a binned spectrum.
|
| 210 |
+
If there is only one item in the binned spectra, this returns a single paired list
|
| 211 |
+
of the form ``[[x values],[y values]]``.
|
| 212 |
+
"""
|
| 213 |
+
out = []
|
| 214 |
+
delta = 0
|
| 215 |
+
spec = Spectrum(
|
| 216 |
+
dec,
|
| 217 |
+
start=start - 1,
|
| 218 |
+
end=end + 1,
|
| 219 |
+
)
|
| 220 |
+
for ind, (x, y) in enumerate(lst): # for each timepoint
|
| 221 |
+
delta += 1
|
| 222 |
+
sys.stdout.write('\rBinning spectrum #%i/%i %.1f%%' % (ind + 1, len(lst), float(ind) / float(len(lst)) * 100.))
|
| 223 |
+
spec.add_spectrum(x, y) # add spectrum
|
| 224 |
+
if delta == n: # critical number is reached
|
| 225 |
+
out.append(spec.trim(zeros=True)) # append list
|
| 226 |
+
spec.reset_y() # reset y list in object
|
| 227 |
+
delta = 0 # reset critical sum
|
| 228 |
+
sys.stdout.write(' DONE\n')
|
| 229 |
+
if len(out) == 1: # if there is only one item
|
| 230 |
+
return out[0]
|
| 231 |
+
return out
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def bincidspectra(speclist, celist, dec=3, startmz=50., endmz=2000., threshold=0, fillzeros=False):
|
| 235 |
+
"""
|
| 236 |
+
Bins mass spectra together based on the collision voltage of associated with each spectrum.
|
| 237 |
+
|
| 238 |
+
**Parameters**
|
| 239 |
+
|
| 240 |
+
speclist: *list*
|
| 241 |
+
A list of lists of the form ``[ [[x1,x2,...,xn],[y1,y2,...,yn]] , [[],[]] ,...]``
|
| 242 |
+
where each index of the parent list is one paired spectrum of x and y values.
|
| 243 |
+
The x values of one index do not have to be the same. The spectra will be combined based on the x value rounded to the nearest 10^-`dec`.
|
| 244 |
+
|
| 245 |
+
celist: *list*
|
| 246 |
+
A list of collision energy values, where each index corresponds to the spectrum at that index of *speclist*. This list must be the same length as *speclist*.
|
| 247 |
+
|
| 248 |
+
dec: *int*
|
| 249 |
+
The decimal place to track the x values to. e.g. ``dec = 3`` would track x values to the nearest 0.001 (10^-3)
|
| 250 |
+
|
| 251 |
+
startmz: *float*, optional
|
| 252 |
+
The minimum mass to charge value to track in the summed spectra.
|
| 253 |
+
|
| 254 |
+
end: *float*, optional
|
| 255 |
+
The maximum mass to charge value to track in the summed spectra.
|
| 256 |
+
|
| 257 |
+
threshold: *float*, optional
|
| 258 |
+
The minimum y value intensity to track.
|
| 259 |
+
|
| 260 |
+
fillzeros: *bool*, optional
|
| 261 |
+
Whether to fill the resulting spectra with 0. for every value of x that does not have intensity.
|
| 262 |
+
|
| 263 |
+
**Returns**
|
| 264 |
+
|
| 265 |
+
specout: *list*
|
| 266 |
+
A list of paired lists (similar to *speclst*) where each index is a binned spectrum.
|
| 267 |
+
|
| 268 |
+
cv: *list*
|
| 269 |
+
A sorted list of collision voltages with each index corresponding to that index in *specout*.
|
| 270 |
+
|
| 271 |
+
"""
|
| 272 |
+
binned = {}
|
| 273 |
+
|
| 274 |
+
for ind, ce in enumerate(celist):
|
| 275 |
+
sys.stdout.write('\rBinning spectrum by CID value #%i/%i %.1f%%' % (
|
| 276 |
+
ind + 1, len(celist), float(ind + 1) / float(len(celist)) * 100.))
|
| 277 |
+
if ce not in binned: # generate key and spectrum object if not present
|
| 278 |
+
binned[ce] = Spectrum(dec, start=startmz, end=endmz)
|
| 279 |
+
else: # otherwise add spectrum
|
| 280 |
+
binned[ce].add_spectrum(speclist[ind][0], speclist[ind][1])
|
| 281 |
+
|
| 282 |
+
if threshold > 0 or fillzeros is True: # if manipulation is called for
|
| 283 |
+
for vol in binned: # for each voltage
|
| 284 |
+
sys.stdout.write('\rZero filling spectrum for %s eV' % str(vol))
|
| 285 |
+
if threshold > 0:
|
| 286 |
+
binned[vol].threshold(threshold) # apply threshold
|
| 287 |
+
if fillzeros is True:
|
| 288 |
+
binned[vol].fill_with_zeros() # fill with zeros
|
| 289 |
+
sys.stdout.write(' DONE\n')
|
| 290 |
+
|
| 291 |
+
cv = [] # list for collision voltages
|
| 292 |
+
specout = [] # list for spectra
|
| 293 |
+
for vol, spec in sorted(binned.items()):
|
| 294 |
+
sys.stdout.write('\rTrimming spectrum for %s eV' % str(vol))
|
| 295 |
+
cv.append(vol) # append voltage to list
|
| 296 |
+
specout.append(spec.trim()) # append trimmed spectrum to list
|
| 297 |
+
sys.stdout.write(' DONE\n')
|
| 298 |
+
sys.stdout.flush()
|
| 299 |
+
return specout, cv
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def find_all(fname, path):
|
| 303 |
+
"""
|
| 304 |
+
Finds all files matching a specified name within the directory specified.
|
| 305 |
+
|
| 306 |
+
**Parameters**
|
| 307 |
+
|
| 308 |
+
fname: *string*
|
| 309 |
+
The name of the file to be located
|
| 310 |
+
|
| 311 |
+
path: *string*
|
| 312 |
+
The absolute directory path to search.
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
**Returns**
|
| 316 |
+
|
| 317 |
+
list of locations: *list*
|
| 318 |
+
A list of all possible paths matching the filename in the specified directory.
|
| 319 |
+
|
| 320 |
+
"""
|
| 321 |
+
locations = []
|
| 322 |
+
for root, dirs, files in os.walk(path):
|
| 323 |
+
if fname in files:
|
| 324 |
+
locations.append(os.path.join(root, fname))
|
| 325 |
+
return locations
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def linmag(vali, magstart, magend, dur):
|
| 329 |
+
"""
|
| 330 |
+
Generates a ramp of values that is linear in magnification.
|
| 331 |
+
|
| 332 |
+
**Parameters**
|
| 333 |
+
|
| 334 |
+
vali: *float*
|
| 335 |
+
The initial y value at the start of the ramp.
|
| 336 |
+
|
| 337 |
+
magstart: *float*
|
| 338 |
+
The magnification at the start of the ramp.
|
| 339 |
+
|
| 340 |
+
magend: *float*
|
| 341 |
+
The magnification at the end of the ramp.
|
| 342 |
+
|
| 343 |
+
dur: *int*
|
| 344 |
+
The desired number of steps to get from *magstart* to *magend*.
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
**Returns**
|
| 348 |
+
|
| 349 |
+
list of magnifications: *list*
|
| 350 |
+
A list of magnifications corresponding to the ramp.
|
| 351 |
+
|
| 352 |
+
"""
|
| 353 |
+
out = []
|
| 354 |
+
for i in range(dur):
|
| 355 |
+
out.append(float(vali) / ((magend - magstart) / dur * i + magstart))
|
| 356 |
+
return out
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def linramp(valstart, valend, dur):
|
| 360 |
+
"""
|
| 361 |
+
Generates a linear ramp of values.
|
| 362 |
+
|
| 363 |
+
**Parameters**
|
| 364 |
+
|
| 365 |
+
valstart: *float*
|
| 366 |
+
The value at the start of the ramp.
|
| 367 |
+
|
| 368 |
+
valend: *float*
|
| 369 |
+
The value at the end of the ramp.
|
| 370 |
+
|
| 371 |
+
dur: *int*
|
| 372 |
+
The number of steps in the ramp.
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
**Returns**
|
| 376 |
+
|
| 377 |
+
List of ramped values: *list*
|
| 378 |
+
|
| 379 |
+
"""
|
| 380 |
+
out = []
|
| 381 |
+
for i in range(int(dur)):
|
| 382 |
+
out.append(((float(valend - valstart)) / (float(dur))) * i + valstart)
|
| 383 |
+
return out
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def locate_in_list(lst, value, bias='closest', within=0.1):
|
| 387 |
+
"""
|
| 388 |
+
Finds index in a sorted list of the value closest to a given value
|
| 389 |
+
|
| 390 |
+
If two numbers are equally close, return the smallest number.
|
| 391 |
+
roughly based on http://stackoverflow.com/questions/12141150/from-list-of-integers-get-number-closest-to-a-given-value
|
| 392 |
+
|
| 393 |
+
:param lst: list of values to search
|
| 394 |
+
:param value: value number to find
|
| 395 |
+
:param bias: 'lesser' will return the position of the value just less than the provided value.
|
| 396 |
+
'greater' will return the position of the value just greater than the provided value.
|
| 397 |
+
'closest' will return the index of the nearest value to the one provided
|
| 398 |
+
:param within: If the bias is closest, the position will only be returned if the position is this value away from
|
| 399 |
+
the actual value
|
| 400 |
+
:return: index of the position
|
| 401 |
+
:rtype: int
|
| 402 |
+
"""
|
| 403 |
+
pos = bisect_left(lst, value)
|
| 404 |
+
if pos == 0: # if at start of list
|
| 405 |
+
return pos
|
| 406 |
+
elif pos == len(lst): # if insertion is beyond index range
|
| 407 |
+
return pos - 1
|
| 408 |
+
if lst[pos] == value: # if an exact match is found
|
| 409 |
+
return pos
|
| 410 |
+
if bias == 'greater': # return value greater than the value (bisect_left has an inherent bias to the right)
|
| 411 |
+
return pos
|
| 412 |
+
if bias == 'lesser': # return value lesser than the provided
|
| 413 |
+
return pos - 1
|
| 414 |
+
if bias == 'closest': # check differences between index and index-1 and actual value, return closest
|
| 415 |
+
adjval = abs(lst[pos - 1] - value)
|
| 416 |
+
curval = abs(lst[pos] - value)
|
| 417 |
+
if adjval > within and curval > within: # if the value is outside of the lookwithin bounds
|
| 418 |
+
return None
|
| 419 |
+
if adjval < curval: # if the lesser value is closer
|
| 420 |
+
return pos - 1
|
| 421 |
+
if adjval == curval: # if values are equidistant
|
| 422 |
+
return pos - 1
|
| 423 |
+
else:
|
| 424 |
+
return pos
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def lyround(x, basen):
|
| 428 |
+
"""
|
| 429 |
+
Rounds the specified number using a specific base
|
| 430 |
+
|
| 431 |
+
**Parameters**
|
| 432 |
+
|
| 433 |
+
x: *float*
|
| 434 |
+
The value to be rounded
|
| 435 |
+
|
| 436 |
+
basen: *int*
|
| 437 |
+
The number base to use for rounding
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
**Returns**
|
| 441 |
+
|
| 442 |
+
value: *float*
|
| 443 |
+
The rounded value.
|
| 444 |
+
|
| 445 |
+
**Notes**
|
| 446 |
+
|
| 447 |
+
This function is based on http://stackoverflow.com/questions/2272149/round-to-5-or-other-number-in-python
|
| 448 |
+
"""
|
| 449 |
+
base = basen ** (int(len(str(int(x)))) - 1)
|
| 450 |
+
return int(base * round(float(x) / base))
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def mag(initial, current):
|
| 454 |
+
"""
|
| 455 |
+
Calculates the magnification of a specified value
|
| 456 |
+
|
| 457 |
+
**Parameters**
|
| 458 |
+
|
| 459 |
+
intial: *float*
|
| 460 |
+
initial value (magnificiation of 1)
|
| 461 |
+
|
| 462 |
+
current: *float*
|
| 463 |
+
current value
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
**Returns**
|
| 467 |
+
|
| 468 |
+
magnification: *float*
|
| 469 |
+
the magnification of the current value
|
| 470 |
+
"""
|
| 471 |
+
return float(initial) / float(current)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def normalize(lst, maxval=1.):
|
| 475 |
+
"""
|
| 476 |
+
Normalizes a list of values with a specified value.
|
| 477 |
+
|
| 478 |
+
**Parameters**
|
| 479 |
+
|
| 480 |
+
lst: *list*
|
| 481 |
+
List of values to be normalized
|
| 482 |
+
|
| 483 |
+
maxval: *float*, optional
|
| 484 |
+
The maximum value that the list will have after normalization.
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
**Returns**
|
| 488 |
+
|
| 489 |
+
normalized list: *list*
|
| 490 |
+
A list of values normalized to the specified value.
|
| 491 |
+
|
| 492 |
+
"""
|
| 493 |
+
listmax = max(lst)
|
| 494 |
+
for ind, val in enumerate(lst):
|
| 495 |
+
lst[ind] = float(val) / float(listmax) * maxval
|
| 496 |
+
return lst
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def localmax(x: list, y: list, xval: float, lookwithin: float = 1.):
|
| 500 |
+
"""
|
| 501 |
+
Finds the local maximum within +/- lookwithin of the xval
|
| 502 |
+
|
| 503 |
+
:param x: x list
|
| 504 |
+
:param y: y list
|
| 505 |
+
:param xval:
|
| 506 |
+
:param lookwithin:
|
| 507 |
+
:return: maximum y value
|
| 508 |
+
:rtype: float
|
| 509 |
+
"""
|
| 510 |
+
l = bisect_left(x, xval - lookwithin)
|
| 511 |
+
r = bisect_right(x, xval + lookwithin)
|
| 512 |
+
return max(y[l:r])
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def trimspectrum(x: list, y: list, left: float, right: float, outside: bool = False):
|
| 516 |
+
"""
|
| 517 |
+
Trims a spectrum to the left and right bounds specified
|
| 518 |
+
|
| 519 |
+
:param x: x value list
|
| 520 |
+
:param y: y value list
|
| 521 |
+
:param left: left trim value
|
| 522 |
+
:param right: right trim value
|
| 523 |
+
:param outside: Whether to include the next point outside of the trimmed spectrum. This provides continuity if the
|
| 524 |
+
spectrum is to be used for image generation.
|
| 525 |
+
:return: new spectrum
|
| 526 |
+
:rtype: tuple of list
|
| 527 |
+
"""
|
| 528 |
+
# find indicies
|
| 529 |
+
l = locate_in_list(x, left, 'greater')
|
| 530 |
+
r = locate_in_list(x, right, 'lesser')
|
| 531 |
+
if outside is True:
|
| 532 |
+
l -= 1
|
| 533 |
+
r += 1
|
| 534 |
+
return x[l:r + 1], y[l:r + 1] # trim spectrum
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def estimated_exact_mass(
|
| 538 |
+
x: list,
|
| 539 |
+
y: list,
|
| 540 |
+
em: float,
|
| 541 |
+
simmin: float,
|
| 542 |
+
simmax: float,
|
| 543 |
+
lookwithin: float = 1,
|
| 544 |
+
):
|
| 545 |
+
"""
|
| 546 |
+
Estimates the exact mass of a peak in a spectrum within a provided simulated set of bounds
|
| 547 |
+
|
| 548 |
+
:param x: x value list
|
| 549 |
+
:param y: y value list
|
| 550 |
+
:param em: estimated exact mass for the species
|
| 551 |
+
:param simmin: minimum bound for the simulated isotope pattern
|
| 552 |
+
:param simmax: maximum bounds for the simulated isotope pattern
|
| 553 |
+
:param lookwithin: +/- bounds for the search
|
| 554 |
+
:return: estimated exact mass
|
| 555 |
+
:rtype: float
|
| 556 |
+
"""
|
| 557 |
+
# narrow range to that of the isotope pattern
|
| 558 |
+
l = bisect_left(x, simmin - lookwithin)
|
| 559 |
+
r = bisect_right(x, simmax + lookwithin)
|
| 560 |
+
locmax = max(y[l:r]) # find local max in that range
|
| 561 |
+
for ind, val in enumerate(y):
|
| 562 |
+
if val == locmax: # if the y-value equals the local max
|
| 563 |
+
if l <= ind <= r: # and if the index is in the range (avoids false locations)
|
| 564 |
+
return x[ind]
|
| 565 |
+
difleft = abs(em - simmin)
|
| 566 |
+
difright = abs(em - simmax)
|
| 567 |
+
return '>%.1f' % max(difleft, difright) # if no match is found, return maximum difference
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
# TODO change simdict to be nonmutable
|
| 571 |
+
def plot_mass_spectrum(
|
| 572 |
+
realspec,
|
| 573 |
+
simdict={},
|
| 574 |
+
mz='auto', # m/z bounds for the output spectrum
|
| 575 |
+
outname='spectrum', # name for the output file
|
| 576 |
+
output='save', # 'save' or 'show' the figure
|
| 577 |
+
simtype='bar', # simulation overlay type ('bar' or 'gaussian')
|
| 578 |
+
spectype='continuum', # spectrum type ('continuum' or 'centroid')
|
| 579 |
+
maxy='max', # max or value
|
| 580 |
+
norm=True, # True or False
|
| 581 |
+
simnorm='spec', # top, spec, or value
|
| 582 |
+
xlabel=True, # show x label
|
| 583 |
+
ylabel=True, # show y label
|
| 584 |
+
xvalues=True, # show x values
|
| 585 |
+
yvalues=True, # show y values
|
| 586 |
+
showx=True, # show x axis
|
| 587 |
+
showy=True, # how y axis
|
| 588 |
+
offsetx=True, # offset x axis (shows low intensity species better)
|
| 589 |
+
fs=16, # font size
|
| 590 |
+
lw=1.5, # line width for the plotted spectrum
|
| 591 |
+
axwidth=1.5, # axis width
|
| 592 |
+
simlabels=False, # show labels isotope for patterns
|
| 593 |
+
bw='auto', # bar width for isotope patterns (auto does 2*fwhm)
|
| 594 |
+
specfont='Arial', # the font for text in the plot
|
| 595 |
+
size=[7.87, 4.87], # size in inches for the figure
|
| 596 |
+
dpiout=300, # dpi for the output figure
|
| 597 |
+
exten='png', # extension for the output figure
|
| 598 |
+
resolution=None, # resolution to use for simulations (if not specified, automatically calculates)
|
| 599 |
+
res_label=False, # output the resolution of the spectrum
|
| 600 |
+
delta=False, # output the mass delta between the spectrum and the isotope patterns
|
| 601 |
+
stats=False, # output the goodness of match between the spectrum and the predicted isotope patterns,
|
| 602 |
+
speccolour='k', # colour for the spectrum to be plotted
|
| 603 |
+
padding='auto', # padding for the output plot
|
| 604 |
+
verbose=True, # verbose setting
|
| 605 |
+
normwindow='fwhm', # the width of the window to look for a maximal value around the expected exact mass for a peak
|
| 606 |
+
annotations=None, # annotations for the spectrum in dictionary form {'thing to print':[x,y],}
|
| 607 |
+
normrel=100., # the maximum value for normalization
|
| 608 |
+
ipmol_kwargs={}, # IPMolecule keyword arguments
|
| 609 |
+
**kwargs
|
| 610 |
+
):
|
| 611 |
+
"""
|
| 612 |
+
Plots and saves a publication quality mass spectrum with optional overlaid isotope patterns
|
| 613 |
+
|
| 614 |
+
:param list realspec: A paired list of x and y values of the form ``[[x values],[y values]]``
|
| 615 |
+
:param dict simdict: This can either be a molecular formula to predict the isotope pattern of (string),
|
| 616 |
+
a list of formulae, or a dictionary of the form
|
| 617 |
+
``simdict = {'formula1':{'colour':<hex or name or RGB tuple>, 'alpha':float}, ...}``.
|
| 618 |
+
If this is dictionary is left empty, no isotope patterns will be overlaid on the output
|
| 619 |
+
spectrum.
|
| 620 |
+
:param list mz: The *m/z* bounds for the output spectrum. Default: 'auto', but can be supplied
|
| 621 |
+
with a tuple or list of length 2 of the form ``[x start, x end]``.
|
| 622 |
+
:param str outname: Name of the file to be saved.
|
| 623 |
+
:param str output: Save ('save') or show ('show') the figure.
|
| 624 |
+
:param str simtype: The type for the isotope pattern simulation overlay. Options: 'bar' or 'gaussian'.
|
| 625 |
+
:param str spectype: The type of spectrum being handed to the function. Options: 'continuum' or 'centroid'.
|
| 626 |
+
:param float maxy: The maximum y value for the spectrum. Options: 'max' or specify a value
|
| 627 |
+
:param bool norm: Normalize the spectrum. Options: bool
|
| 628 |
+
:param str, float simnorm: Normalize the isotope pattern simulations to what value. Options: 'top', 'spec', or
|
| 629 |
+
specify a value. Top will normalize the patterns to ``maxy``, and will only function if maxy is not 'max'.
|
| 630 |
+
Spec will normalize the patterns to the maximum spectrum y value within the x bounds of the
|
| 631 |
+
simulated pattern.
|
| 632 |
+
Specifying a value will normalize all isotope patterns to that value.
|
| 633 |
+
:param bool xlabel: Whether to show the label for the *m/z* axis.
|
| 634 |
+
:param bool ylabel: Whether to show the y-axis label.
|
| 635 |
+
:param bool xvalues: Whether to show the values of the x-axis.
|
| 636 |
+
:param bool yvalues: Whether to show the values of the y-axis.
|
| 637 |
+
:param bool showx: Whether to show the x-axis line.
|
| 638 |
+
:param bool showy: Whether to show the y-axis line.
|
| 639 |
+
:param bool offsetx: Whether to offset the x-axis slightly.
|
| 640 |
+
Enabling this shows makes it easier to see low intensity peaks.
|
| 641 |
+
:param int fs: Font size to use for labels.
|
| 642 |
+
:param float lw: Line width for the plotted spectrum.
|
| 643 |
+
:param float axwidth: Line width for the axes and tick marks. Default 1.5
|
| 644 |
+
:param bool simlabels: Whether to show the names of the simulated isotope patterns.
|
| 645 |
+
The names will be exactly as supplied in ``simdict``.
|
| 646 |
+
:param float bw: The width of the bar in *m/z* for bar isotope patterns. Options: 'auto' or float.
|
| 647 |
+
This only has an affect if *simtype* is 'bar'.
|
| 648 |
+
Auto make the bars equal to 2 times the full width at half max of the peak they are simulating.
|
| 649 |
+
:param str specfont: The font to use for text in the plot. The specified font must be accepted by matplotlib.
|
| 650 |
+
:param list size: The size in inches for the output figure. This must be a list of length 2 of the form
|
| 651 |
+
``[width,height]``.
|
| 652 |
+
:param int dpiout: The dots per inch for the output figure.
|
| 653 |
+
:param str exten: The file extension for the output figure. Options: 'png', 'svg', or other supported by matplotlib.
|
| 654 |
+
:param float resolution: Override the auto-resolution calculation with a specified instrument resolution
|
| 655 |
+
:param bool res_label: Whether to output the resolution of the spectrum onto the figure.
|
| 656 |
+
:param bool delta: Whether to calculate and output the mass delta between the exact mass predicted by the isotope
|
| 657 |
+
pattern simulation and the location of the maximum intensity within the bounds specified by *normwindow*.
|
| 658 |
+
:param bool stats: Whether to calculate and output the goodness of fit between the predicted isotope pattern and
|
| 659 |
+
the supplied spectrum. This functionality is still a work in progress.
|
| 660 |
+
:param speccolour: The colour for the real spectrum , # colour for the spectrum to be plotted
|
| 661 |
+
:param list padding: This allows the user to specify the subplot padding of the output figure.
|
| 662 |
+
Options: 'auto' or list of the form ``[left,right,bottom,top]`` scalars.
|
| 663 |
+
:param bool verbose: Verbose option for the script. Options: bool.
|
| 664 |
+
:param float normwindow: The *m/z* window width within with too look for a maximum intensity value.
|
| 665 |
+
This will only have an effect if *delta* is ``True``.
|
| 666 |
+
Options: 'fwhm' for full width at half max or float.
|
| 667 |
+
:param dict annotations: Annotations for the spectrum in dictionary form: ``{'thing to print':[x,y],}``.
|
| 668 |
+
:param normrel: The maximum value for normalization. This can be used to globally set the top value for normalizing
|
| 669 |
+
simulated isotope patterns. This is used most often to show the lack of an isotope pattern in the shown area.
|
| 670 |
+
:param ipmol_kwargs: Keyword arguments to use for IPMolecule calls. See IPMolecule for more details.
|
| 671 |
+
:param kwargs: catch for unused kwargs
|
| 672 |
+
"""
|
| 673 |
+
def checksimdict(dct):
|
| 674 |
+
"""
|
| 675 |
+
checks the type of simdict, converting to dictionary if necessary
|
| 676 |
+
also checks for alpha and colour keys and adds them if necessary (defaulting to key @ 0.5)
|
| 677 |
+
"""
|
| 678 |
+
if type(dct) is not dict:
|
| 679 |
+
if type(dct) is str:
|
| 680 |
+
dct = {dct: {}}
|
| 681 |
+
elif type(dct) is list or type(dct) is tuple:
|
| 682 |
+
tdct = {}
|
| 683 |
+
for i in dct:
|
| 684 |
+
tdct[i] = {}
|
| 685 |
+
dct = tdct
|
| 686 |
+
for species in dct:
|
| 687 |
+
if 'colour' not in dct[species]:
|
| 688 |
+
dct[species]['colour'] = 'k'
|
| 689 |
+
if 'alpha' not in dct[species]:
|
| 690 |
+
dct[species]['alpha'] = 0.5
|
| 691 |
+
return dct
|
| 692 |
+
|
| 693 |
+
if resolution is None:
|
| 694 |
+
if spectype != 'centroid':
|
| 695 |
+
resolution = autoresolution(realspec[0], realspec[1]) # calculate resolution
|
| 696 |
+
else:
|
| 697 |
+
resolution = 5000
|
| 698 |
+
|
| 699 |
+
simdict = checksimdict(simdict) # checks the simulation dictionary
|
| 700 |
+
for species in simdict: # generate Molecule object and set x and y lists
|
| 701 |
+
simdict[species]['colour'] = Colour(simdict[species]['colour'])
|
| 702 |
+
simdict[species]['mol'] = IPMolecule(
|
| 703 |
+
species,
|
| 704 |
+
resolution=resolution,
|
| 705 |
+
**ipmol_kwargs,
|
| 706 |
+
)
|
| 707 |
+
# simdict[species]['mol'] = Molecule(species, res=res, dropmethod='threshold')
|
| 708 |
+
if simtype == 'bar':
|
| 709 |
+
simdict[species]['x'], simdict[species]['y'] = simdict[species]['mol'].barip
|
| 710 |
+
if simtype == 'gaussian':
|
| 711 |
+
simdict[species]['x'], simdict[species]['y'] = simdict[species]['mol'].gausip
|
| 712 |
+
|
| 713 |
+
if mz == 'auto': # automatically determine m/z range
|
| 714 |
+
if verbose is True:
|
| 715 |
+
sys.stdout.write('Automatically determining m/z window')
|
| 716 |
+
mz = [10000000, 0]
|
| 717 |
+
for species in simdict:
|
| 718 |
+
simdict[species]['bounds'] = simdict[species]['mol'].bounds # calculate bounds
|
| 719 |
+
if simdict[species]['bounds'][0] < mz[0]:
|
| 720 |
+
mz[0] = simdict[species]['bounds'][0] - 1
|
| 721 |
+
if simdict[species]['bounds'][1] > mz[1]:
|
| 722 |
+
mz[1] = simdict[species]['bounds'][1] + 1
|
| 723 |
+
if mz == [10000000, 0]:
|
| 724 |
+
mz = [min(realspec[0]), max(realspec[0])]
|
| 725 |
+
if verbose is True:
|
| 726 |
+
sys.stdout.write(': %i - %i\n' % (int(mz[0]), int(mz[1])))
|
| 727 |
+
sys.stdout.flush()
|
| 728 |
+
|
| 729 |
+
realspec[0], realspec[1] = trimspectrum( # trim real spectrum for efficiency
|
| 730 |
+
realspec[0],
|
| 731 |
+
realspec[1],
|
| 732 |
+
mz[0] - 1,
|
| 733 |
+
mz[1] + 1
|
| 734 |
+
)
|
| 735 |
+
|
| 736 |
+
if len(realspec[0]) == 0: # catch for empty spectrum post-trim (usually user error)
|
| 737 |
+
raise ValueError(f'There are no spectral values in the specified m/z bounds ({mz[0]}-{mz[1]}). Common causes: '
|
| 738 |
+
f'no values in the loaded spectrum within the window of interest, an error in specifying the '
|
| 739 |
+
f'molecule(s) to simulate')
|
| 740 |
+
|
| 741 |
+
if norm is True: # normalize spectrum
|
| 742 |
+
realspec[1] = normalize(realspec[1], normrel)
|
| 743 |
+
|
| 744 |
+
for species in simdict: # normalize simulations
|
| 745 |
+
if simnorm == 'spec': # normalize to maximum around exact mass
|
| 746 |
+
if normwindow == 'fwhm': # if default, look within the full width at half max
|
| 747 |
+
window = simdict[species]['mol'].fwhm
|
| 748 |
+
else: # otherwise look within the specified value
|
| 749 |
+
window = normwindow
|
| 750 |
+
simdict[species]['y'] = normalize(
|
| 751 |
+
simdict[species]['y'],
|
| 752 |
+
localmax(
|
| 753 |
+
realspec[0],
|
| 754 |
+
realspec[1],
|
| 755 |
+
simdict[species]['mol'].estimated_exact_mass,
|
| 756 |
+
window
|
| 757 |
+
)
|
| 758 |
+
)
|
| 759 |
+
elif simnorm == 'top': # normalize to top of the y value
|
| 760 |
+
if maxy == 'max':
|
| 761 |
+
raise ValueError('Simulations con only be normalized to the top of the spectrum when the maxy setting '
|
| 762 |
+
'is a specific value')
|
| 763 |
+
simdict[species]['y'] = normalize(simdict[species]['y'], maxy)
|
| 764 |
+
elif type(simnorm) is int or type(simnorm) is float: # normalize to specified value
|
| 765 |
+
simdict[species]['y'] = normalize(simdict[species]['y'], simnorm)
|
| 766 |
+
if delta is True:
|
| 767 |
+
if normwindow == 'fwhm': # if default, look within the full width at half max
|
| 768 |
+
window = simdict[species]['mol'].fwhm
|
| 769 |
+
else: # otherwise look within the specified value
|
| 770 |
+
window = normwindow
|
| 771 |
+
est = estimated_exact_mass( # try to calculate exact mass
|
| 772 |
+
realspec[0],
|
| 773 |
+
realspec[1],
|
| 774 |
+
simdict[species]['mol'].estimated_exact_mass,
|
| 775 |
+
simmin=simdict[species]['mol'].estimated_exact_mass,
|
| 776 |
+
simmax=simdict[species]['mol'].estimated_exact_mass,
|
| 777 |
+
lookwithin=window,
|
| 778 |
+
# min(simdict[species]['x']),
|
| 779 |
+
# max(simdict[species]['x'])
|
| 780 |
+
)
|
| 781 |
+
if type(est) is float:
|
| 782 |
+
simdict[species]['delta'] = '%.3f (%.1f ppm)' % (
|
| 783 |
+
simdict[species]['mol'].estimated_exact_mass - est, simdict[species]['mol'].compare_exact_mass(est))
|
| 784 |
+
else:
|
| 785 |
+
simdict[species]['delta'] = est
|
| 786 |
+
|
| 787 |
+
pl.clf() # clear and close figure if open
|
| 788 |
+
pl.close()
|
| 789 |
+
fig = pl.figure(figsize=tuple(size))
|
| 790 |
+
ax = fig.add_subplot(111)
|
| 791 |
+
|
| 792 |
+
ax.spines["right"].set_visible(False) # hide right and top spines
|
| 793 |
+
ax.spines["top"].set_visible(False)
|
| 794 |
+
|
| 795 |
+
if showx is False:
|
| 796 |
+
ax.spines["bottom"].set_visible(False) # hide bottom axis
|
| 797 |
+
if showy is False:
|
| 798 |
+
ax.spines["left"].set_visible(False) # hide left axis
|
| 799 |
+
|
| 800 |
+
for axis in ["top", "bottom", "left", "right"]:
|
| 801 |
+
ax.spines[axis].set_linewidth(axwidth)
|
| 802 |
+
|
| 803 |
+
if offsetx is True: # offset x axis
|
| 804 |
+
ax.spines["bottom"].set_position(('axes', -0.01))
|
| 805 |
+
|
| 806 |
+
font = {'fontname': specfont, 'fontsize': fs} # font parameters for axis/text labels
|
| 807 |
+
tickfont = pl.matplotlib.font_manager.FontProperties( # font parameters for axis ticks
|
| 808 |
+
family=specfont,
|
| 809 |
+
size=fs
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
ax.set_xlim(mz) # set x bounds
|
| 813 |
+
|
| 814 |
+
if maxy == 'max': # set y bounds
|
| 815 |
+
ax.set_ylim(0., max(realspec[1]))
|
| 816 |
+
top = max(realspec[1])
|
| 817 |
+
elif type(maxy) is int or type(maxy) is float:
|
| 818 |
+
ax.set_ylim(0., maxy)
|
| 819 |
+
top = maxy
|
| 820 |
+
|
| 821 |
+
if simtype == 'bar': # generates zeros for bottom of bars (assumes m/z spacing is equal between patterns)
|
| 822 |
+
for species in simdict:
|
| 823 |
+
simdict[species]['zero'] = []
|
| 824 |
+
for i in simdict[species]['x']:
|
| 825 |
+
simdict[species]['zero'].append(0.)
|
| 826 |
+
for species in simdict: # for each species
|
| 827 |
+
for subsp in simdict: # look at all the species
|
| 828 |
+
if subsp != species: # if it is not itself
|
| 829 |
+
ins = bisect_left(simdict[subsp]['x'], simdict[species]['x'][-1]) # look for insertion point
|
| 830 |
+
if 0 < ins < len(simdict[subsp]['x']): # if species highest m/z is inside subsp list
|
| 831 |
+
for i in range(ins): # add intensity of species to subsp zeros
|
| 832 |
+
# used -ins+i-1 to fix an error, with any luck this won't break it next time
|
| 833 |
+
simdict[subsp]['zero'][i] += simdict[species]['y'][-ins + i]
|
| 834 |
+
# include resolution if specified (and spectrum is not centroid)
|
| 835 |
+
if res_label is True and spectype != 'centroid':
|
| 836 |
+
ax.text(
|
| 837 |
+
mz[1],
|
| 838 |
+
top * 0.95,
|
| 839 |
+
f'resolution: {str(round(resolution))}',
|
| 840 |
+
horizontalalignment='right',
|
| 841 |
+
**font
|
| 842 |
+
)
|
| 843 |
+
|
| 844 |
+
for species in simdict: # plot and label bars
|
| 845 |
+
if simtype == 'bar':
|
| 846 |
+
if bw == 'auto':
|
| 847 |
+
bw = simdict[species]['mol'].fwhm * 2
|
| 848 |
+
else:
|
| 849 |
+
bw = bw
|
| 850 |
+
ax.bar(
|
| 851 |
+
simdict[species]['x'],
|
| 852 |
+
simdict[species]['y'],
|
| 853 |
+
bw,
|
| 854 |
+
alpha=simdict[species]['alpha'],
|
| 855 |
+
color=simdict[species]['colour'].mpl,
|
| 856 |
+
linewidth=0,
|
| 857 |
+
align='center',
|
| 858 |
+
bottom=simdict[species]['zero']
|
| 859 |
+
)
|
| 860 |
+
elif simtype == 'gaussian':
|
| 861 |
+
ax.plot(
|
| 862 |
+
simdict[species]['x'],
|
| 863 |
+
simdict[species]['y'],
|
| 864 |
+
alpha=simdict[species]['alpha'],
|
| 865 |
+
color=simdict[species]['colour'].mpl,
|
| 866 |
+
linewidth=lw
|
| 867 |
+
)
|
| 868 |
+
ax.fill_between(
|
| 869 |
+
simdict[species]['x'],
|
| 870 |
+
0,
|
| 871 |
+
simdict[species]['y'],
|
| 872 |
+
alpha=simdict[species]['alpha'],
|
| 873 |
+
color=simdict[species]['colour'].mpl,
|
| 874 |
+
linewidth=0
|
| 875 |
+
)
|
| 876 |
+
# if any labels are to be shown
|
| 877 |
+
if simlabels is True or stats is True or delta is True:
|
| 878 |
+
string = ''
|
| 879 |
+
bpi = simdict[species]['y'].index(max(simdict[species]['y'])) # index of base peak
|
| 880 |
+
if simlabels is True: # species name
|
| 881 |
+
string += species
|
| 882 |
+
if stats is True or delta is True: # add return if SER or delta is called for
|
| 883 |
+
string += '\n'
|
| 884 |
+
if stats is True: # standard error of regression
|
| 885 |
+
string += f'SER: {simdict[species]["mol"].compare(realspec)} '
|
| 886 |
+
if delta is True: # mass delta
|
| 887 |
+
string += f'mass delta: {simdict[species]["delta"]}'
|
| 888 |
+
ax.text(
|
| 889 |
+
simdict[species]['x'][bpi],
|
| 890 |
+
top * 1.01,
|
| 891 |
+
string,
|
| 892 |
+
color=simdict[species]['colour'].mpl,
|
| 893 |
+
horizontalalignment='center',
|
| 894 |
+
**font
|
| 895 |
+
)
|
| 896 |
+
|
| 897 |
+
if spectype == 'continuum':
|
| 898 |
+
ax.plot(
|
| 899 |
+
realspec[0],
|
| 900 |
+
realspec[1],
|
| 901 |
+
linewidth=lw,
|
| 902 |
+
color=Colour(speccolour).mpl
|
| 903 |
+
)
|
| 904 |
+
elif spectype == 'centroid':
|
| 905 |
+
dist = []
|
| 906 |
+
for ind, val in enumerate(realspec[0]): # find distance between all adjacent m/z values
|
| 907 |
+
if ind == 0:
|
| 908 |
+
continue
|
| 909 |
+
dist.append(realspec[0][ind] - realspec[0][ind - 1])
|
| 910 |
+
dist = sum(dist) / len(dist) # average distance
|
| 911 |
+
ax.bar(
|
| 912 |
+
realspec[0],
|
| 913 |
+
realspec[1],
|
| 914 |
+
dist * 0.75,
|
| 915 |
+
linewidth=0,
|
| 916 |
+
color=Colour(speccolour).mpl,
|
| 917 |
+
align='center',
|
| 918 |
+
alpha=0.8
|
| 919 |
+
)
|
| 920 |
+
|
| 921 |
+
if annotations is not None:
|
| 922 |
+
for label in annotations:
|
| 923 |
+
ax.text(
|
| 924 |
+
annotations[label][0],
|
| 925 |
+
annotations[label][1],
|
| 926 |
+
label,
|
| 927 |
+
horizontalalignment='center',
|
| 928 |
+
**font
|
| 929 |
+
)
|
| 930 |
+
|
| 931 |
+
# show or hide axis values/labels as specified
|
| 932 |
+
if yvalues is False: # y tick marks and values
|
| 933 |
+
ax.tick_params(axis='y', labelleft='off', length=0)
|
| 934 |
+
else: # y value labels
|
| 935 |
+
ax.tick_params(
|
| 936 |
+
axis='y',
|
| 937 |
+
length=axwidth * 3,
|
| 938 |
+
width=axwidth,
|
| 939 |
+
direction='out',
|
| 940 |
+
right=False,
|
| 941 |
+
)
|
| 942 |
+
for label in ax.get_yticklabels():
|
| 943 |
+
label.set_fontproperties(tickfont)
|
| 944 |
+
if ylabel is True: # y unit
|
| 945 |
+
if top == 100: # normalized
|
| 946 |
+
ax.set_ylabel('relative intensity', **font)
|
| 947 |
+
else: # set to counts
|
| 948 |
+
ax.set_ylabel('intensity (counts)', **font)
|
| 949 |
+
|
| 950 |
+
if xvalues is False: # x tick marks and values
|
| 951 |
+
ax.tick_params(axis='x', labelbottom='off', length=0)
|
| 952 |
+
else: # x value labels
|
| 953 |
+
ax.tick_params(
|
| 954 |
+
axis='x',
|
| 955 |
+
length=axwidth * 3,
|
| 956 |
+
width=axwidth,
|
| 957 |
+
direction='out',
|
| 958 |
+
top=False,
|
| 959 |
+
)
|
| 960 |
+
for label in ax.get_xticklabels():
|
| 961 |
+
label.set_fontproperties(tickfont)
|
| 962 |
+
if xlabel is True: # x unit
|
| 963 |
+
ax.set_xlabel('m/z', style='italic', **font)
|
| 964 |
+
|
| 965 |
+
pl.ticklabel_format(useOffset=False) # don't use the stupid shorthand thing
|
| 966 |
+
if padding == 'auto':
|
| 967 |
+
pl.tight_layout(pad=0.5) # adjust subplots
|
| 968 |
+
if simlabels is True or stats is True or delta is True:
|
| 969 |
+
pl.subplots_adjust(top=0.90) # lower top if details are called for
|
| 970 |
+
elif type(padding) is list and len(padding) == 4:
|
| 971 |
+
pl.subplots_adjust(
|
| 972 |
+
left=padding[0],
|
| 973 |
+
right=padding[1],
|
| 974 |
+
bottom=padding[2],
|
| 975 |
+
top=padding[3]
|
| 976 |
+
)
|
| 977 |
+
|
| 978 |
+
if output == 'save': # save figure
|
| 979 |
+
outname = '' # generate tag for filenaming
|
| 980 |
+
for species in simdict:
|
| 981 |
+
outname += ' ' + species
|
| 982 |
+
outname = outname + outname + '.' + exten
|
| 983 |
+
pl.savefig(
|
| 984 |
+
outname,
|
| 985 |
+
dpi=dpiout,
|
| 986 |
+
format=exten,
|
| 987 |
+
transparent=True
|
| 988 |
+
)
|
| 989 |
+
if verbose is True:
|
| 990 |
+
sys.stdout.write('Saved figure as:\n"%s"\nin the working directory' % outname)
|
| 991 |
+
|
| 992 |
+
elif output == 'show': # show figure
|
| 993 |
+
pl.show()
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
def plotuv(wavelengths, intensities, **kwargs):
|
| 997 |
+
"""
|
| 998 |
+
Plots and saves a publication quality UV-Vis figure.
|
| 999 |
+
|
| 1000 |
+
**Parameters**
|
| 1001 |
+
|
| 1002 |
+
wavelengths: *list*
|
| 1003 |
+
A list of wavelengths
|
| 1004 |
+
|
| 1005 |
+
intensities: *list*
|
| 1006 |
+
A list of intensity values paired by index to *wavelengths*
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
**Returns**
|
| 1010 |
+
|
| 1011 |
+
return item: ``None``
|
| 1012 |
+
This function has no pythonic return.
|
| 1013 |
+
|
| 1014 |
+
**\*\*kwargs**
|
| 1015 |
+
|
| 1016 |
+
axwidth: 1.5
|
| 1017 |
+
Line width for the axes and tick marks. Options: float.
|
| 1018 |
+
|
| 1019 |
+
colours:
|
| 1020 |
+
A list of colours to be used if the fuction is supplied with multiple traces.
|
| 1021 |
+
|
| 1022 |
+
dpiout: 300
|
| 1023 |
+
The dots per inch for the output figure. Options: integer.
|
| 1024 |
+
|
| 1025 |
+
exten: 'png'
|
| 1026 |
+
The file extension for the output figure. Options: 'png', 'svg', or other supported by matplotlib.
|
| 1027 |
+
|
| 1028 |
+
fs: 16
|
| 1029 |
+
Font size to use for labels. Options: integer or float.
|
| 1030 |
+
|
| 1031 |
+
legloc: 0
|
| 1032 |
+
The matplotlib legend location key.
|
| 1033 |
+
See http://matplotlib.org/api/legend_api.html for location codes.
|
| 1034 |
+
|
| 1035 |
+
lw: 1.5
|
| 1036 |
+
Line width for the plotted spectrum. Options: float.
|
| 1037 |
+
|
| 1038 |
+
outname: 'UV-Vis spectrum'
|
| 1039 |
+
Name for the output file. Options: string.
|
| 1040 |
+
|
| 1041 |
+
output: 'save'
|
| 1042 |
+
Save ('save') or show ('show') the figure.
|
| 1043 |
+
|
| 1044 |
+
padding: 'auto'
|
| 1045 |
+
This allows the user to specify the subplot padding of the output figure.
|
| 1046 |
+
Options: 'auto' or list of the form ``[left,right,bottom,top]`` scalars.
|
| 1047 |
+
|
| 1048 |
+
size: [7.87,4.87]
|
| 1049 |
+
The size in inches for the output figure. This must be a list of length 2 of the form
|
| 1050 |
+
``[width,height]``.
|
| 1051 |
+
|
| 1052 |
+
specfont: 'Arial'
|
| 1053 |
+
The font to use for text in the plot. The specified font must be accepted by matplotlib.
|
| 1054 |
+
|
| 1055 |
+
times: None
|
| 1056 |
+
A list of timepoints for each provided trace. These are used as labels in the legend.
|
| 1057 |
+
|
| 1058 |
+
verbose: True
|
| 1059 |
+
Verbose option for the script. Options: bool.
|
| 1060 |
+
|
| 1061 |
+
xrange: None
|
| 1062 |
+
The limits for the x axis. Options None or ``[x min,x max]``
|
| 1063 |
+
|
| 1064 |
+
yrange: None
|
| 1065 |
+
The limits for the y axis. Options None or ``[y min,y max]``
|
| 1066 |
+
|
| 1067 |
+
|
| 1068 |
+
"""
|
| 1069 |
+
settings = { # default settings for the function
|
| 1070 |
+
'outname': 'UV-Vis spectrum', # name for the output file
|
| 1071 |
+
'fs': 16, # font size
|
| 1072 |
+
'lw': 1.5, # line width for the plotted spectrum
|
| 1073 |
+
'axwidth': 1.5, # axis width
|
| 1074 |
+
'size': [7.87, 4.87], # size in inches for the figure
|
| 1075 |
+
'dpiout': 300, # dpi for the output figure
|
| 1076 |
+
'exten': 'png', # extension for the output figure
|
| 1077 |
+
'specfont': 'Arial', # the font for text in the plot
|
| 1078 |
+
# colours to use for multiple traces in the same spectrum (feel free to specify your own)
|
| 1079 |
+
'colours': ['#a6cee3', '#1f78b4', '#b2df8a', '#33a02c', '#fb9a99', '#e31a1c', '#fdbf6f', '#ff7f00', '#cab2d6',
|
| 1080 |
+
'#6a3d9a', '#ffff99', '#8dd3c7', '#ffffb3', '#bebada', '#fb8072', '#80b1d3', '#fdb462', '#b3de69',
|
| 1081 |
+
'#fccde5', '#d9d9d9', '#bc80bd', '#ccebc5', ],
|
| 1082 |
+
'xrange': None, # the limits for the x axis
|
| 1083 |
+
'yrange': None, # the limits for the y axis
|
| 1084 |
+
'times': None, # time points for each provided trace (for legend labels)
|
| 1085 |
+
'output': 'save', # 'save' or 'show' the figure
|
| 1086 |
+
'padding': None, # padding for the output plot
|
| 1087 |
+
'verbose': True, # chatty
|
| 1088 |
+
'legloc': 0, # legend location (see http://matplotlib.org/api/legend_api.html location codes)
|
| 1089 |
+
}
|
| 1090 |
+
if set(kwargs.keys()) - set(settings.keys()): # check for invalid keyword arguments
|
| 1091 |
+
string = ''
|
| 1092 |
+
for i in set(kwargs.keys()) - set(settings.keys()):
|
| 1093 |
+
string += ' %s' % i
|
| 1094 |
+
raise KeyError('Unsupported keyword argument(s): %s' % string)
|
| 1095 |
+
|
| 1096 |
+
settings.update(kwargs) # update settings from keyword arguments
|
| 1097 |
+
|
| 1098 |
+
pl.clf() # clear and close figure if open
|
| 1099 |
+
pl.close()
|
| 1100 |
+
fig = pl.figure(figsize=tuple(settings['size']))
|
| 1101 |
+
ax = fig.add_subplot(111)
|
| 1102 |
+
|
| 1103 |
+
ax.spines["right"].set_visible(False) # hide right and top spines
|
| 1104 |
+
ax.spines["top"].set_visible(False)
|
| 1105 |
+
|
| 1106 |
+
font = {'fontname': settings['specfont'], 'fontsize': settings['fs']} # font parameters for axis/text labels
|
| 1107 |
+
tickfont = pl.matplotlib.font_manager.FontProperties(family=settings['specfont'],
|
| 1108 |
+
size=settings['fs']) # font parameters for axis ticks
|
| 1109 |
+
|
| 1110 |
+
if type(intensities[0]) is float: # if the function has only been handed a single spectrum
|
| 1111 |
+
intensities = [intensities]
|
| 1112 |
+
|
| 1113 |
+
# determine and set limits for axes
|
| 1114 |
+
if settings['xrange'] is None: # auto determine x limits
|
| 1115 |
+
settings['xrange'] = [min(wavelengths), max(wavelengths)]
|
| 1116 |
+
if settings['yrange'] is None: # auto determine y limits
|
| 1117 |
+
settings['yrange'] = [0, 0]
|
| 1118 |
+
for spec in intensities:
|
| 1119 |
+
if max(spec) > settings['yrange'][1]:
|
| 1120 |
+
settings['yrange'][1] = max(spec)
|
| 1121 |
+
ax.set_xlim(settings['xrange']) # set x bounds
|
| 1122 |
+
ax.set_ylim(settings['yrange']) # set y bounds
|
| 1123 |
+
|
| 1124 |
+
# apply font and tick parameters to axes
|
| 1125 |
+
ax.tick_params(axis='x', length=settings['axwidth'] * 3, width=settings['axwidth'], direction='out', top='off')
|
| 1126 |
+
for label in ax.get_xticklabels():
|
| 1127 |
+
label.set_fontproperties(tickfont)
|
| 1128 |
+
ax.tick_params(axis='y', length=settings['axwidth'] * 3, width=settings['axwidth'], direction='out', right='off')
|
| 1129 |
+
for label in ax.get_yticklabels():
|
| 1130 |
+
label.set_fontproperties(tickfont)
|
| 1131 |
+
for axis in ["top", "bottom", "left", "right"]:
|
| 1132 |
+
ax.spines[axis].set_linewidth(settings['axwidth'])
|
| 1133 |
+
|
| 1134 |
+
if settings['times'] is not None:
|
| 1135 |
+
if len(settings['times']) != len(intensities):
|
| 1136 |
+
raise IndexError('The numer of times provided do not match the number of traces provided.')
|
| 1137 |
+
|
| 1138 |
+
for ind, spec in enumerate(intensities): # plot traces
|
| 1139 |
+
if settings['times'] is not None:
|
| 1140 |
+
string = 't = ' + str(round(settings['times'][ind], 1)) + 'm'
|
| 1141 |
+
ax.plot(wavelengths, spec, label=string, color=Colour(settings['colours'][ind]).mpl,
|
| 1142 |
+
linewidth=settings['lw'])
|
| 1143 |
+
else:
|
| 1144 |
+
ax.plot(wavelengths, spec, color=Colour(settings['colours'][ind]).mpl, linewidth=settings['lw'])
|
| 1145 |
+
|
| 1146 |
+
if settings['times'] is not None:
|
| 1147 |
+
ax.legend(loc=0, frameon=False)
|
| 1148 |
+
|
| 1149 |
+
ax.set_xlabel('wavelength (nm)', **font)
|
| 1150 |
+
ax.set_ylabel('absorbance (a.u.)', **font)
|
| 1151 |
+
|
| 1152 |
+
if settings['padding'] is None:
|
| 1153 |
+
pl.tight_layout(pad=0.5) # adjust subplots
|
| 1154 |
+
elif type(settings['padding']) is list and len(settings['padding']) == 4:
|
| 1155 |
+
pl.subplots_adjust(left=settings['padding'][0], right=settings['padding'][1], bottom=settings['padding'][2],
|
| 1156 |
+
top=settings['padding'][3])
|
| 1157 |
+
|
| 1158 |
+
if settings['output'] == 'save': # save figure
|
| 1159 |
+
outname = settings['outname'] + '.' + settings['exten']
|
| 1160 |
+
pl.savefig(outname, dpi=settings['dpiout'], format=settings['exten'], transparent=True)
|
| 1161 |
+
if settings['verbose'] is True:
|
| 1162 |
+
sys.stdout.write('Saved figure as:\n"%s"\nin the working directory' % outname)
|
| 1163 |
+
|
| 1164 |
+
elif settings['output'] == 'show': # show figure
|
| 1165 |
+
pl.show()
|
| 1166 |
+
|
| 1167 |
+
|
| 1168 |
+
def sigmafwhm(res, x):
|
| 1169 |
+
"""
|
| 1170 |
+
Calculates the full width at half max and standard deviation for a spectrum peak.
|
| 1171 |
+
|
| 1172 |
+
**Parameters**
|
| 1173 |
+
|
| 1174 |
+
res: *float*
|
| 1175 |
+
The resolution of the peak in question
|
| 1176 |
+
|
| 1177 |
+
x: *float*
|
| 1178 |
+
The x value of the peak in question
|
| 1179 |
+
|
| 1180 |
+
|
| 1181 |
+
**Returns**
|
| 1182 |
+
|
| 1183 |
+
fwhm: *float*
|
| 1184 |
+
The full width at half max of the peak.
|
| 1185 |
+
|
| 1186 |
+
sigma: *float*
|
| 1187 |
+
The standard deviation of the peak.
|
| 1188 |
+
|
| 1189 |
+
"""
|
| 1190 |
+
fwhm = x / res
|
| 1191 |
+
sigma = fwhm / (2 * np.sqrt(2 * np.log(2))) # based on the equation FWHM = 2*sqrt(2ln2)*sigma
|
| 1192 |
+
return fwhm, sigma
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
def strtolist(string):
|
| 1196 |
+
"""
|
| 1197 |
+
Converts a string to a list with more flexibility than ``string.split()``
|
| 1198 |
+
by looking for both brackets of type ``(,),[,],{,}`` and commas.
|
| 1199 |
+
|
| 1200 |
+
**Parameters**
|
| 1201 |
+
|
| 1202 |
+
string: *string*
|
| 1203 |
+
The string to be split.
|
| 1204 |
+
|
| 1205 |
+
|
| 1206 |
+
**Returns**
|
| 1207 |
+
|
| 1208 |
+
split list: *list*
|
| 1209 |
+
The split list
|
| 1210 |
+
|
| 1211 |
+
|
| 1212 |
+
**Examples**
|
| 1213 |
+
|
| 1214 |
+
::
|
| 1215 |
+
|
| 1216 |
+
>>> strtolist('[(12.3,15,256.128)]')
|
| 1217 |
+
[12.3, 15, 256.128]
|
| 1218 |
+
|
| 1219 |
+
"""
|
| 1220 |
+
out = []
|
| 1221 |
+
temp = ''
|
| 1222 |
+
brackets = ['(', ')', '[', ']', '{', '}']
|
| 1223 |
+
for char in list(string):
|
| 1224 |
+
if char not in brackets and char != ',':
|
| 1225 |
+
temp += char
|
| 1226 |
+
if char == ',':
|
| 1227 |
+
try:
|
| 1228 |
+
out.append(int(temp))
|
| 1229 |
+
except ValueError:
|
| 1230 |
+
out.append(float(temp))
|
| 1231 |
+
temp = ''
|
| 1232 |
+
if len(temp) != 0: # if there is a weird ending character
|
| 1233 |
+
try:
|
| 1234 |
+
out.append(int(temp))
|
| 1235 |
+
except ValueError:
|
| 1236 |
+
out.append(float(temp))
|
| 1237 |
+
return out
|
sample_data/GG208.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
templates/index.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|