viviandlin commited on
Commit
33ba306
·
0 Parent(s):

Initial release of NucleoSpec

Browse files
.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