• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pinneylab / htbam_analysis / 15030452631

14 May 2025 08:30PM UTC coverage: 18.826%. First build
15030452631

Pull #29

github

duncan-muir
redo github token
Pull Request #29: Binding processing refactor

26 of 69 new or added lines in 2 files covered. (37.68%)

465 of 2470 relevant lines covered (18.83%)

0.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

26.7
/src/htbam_analysis/processing/chipcollections.py
1
# title             : chipcollections.py
2
# description       :
3
# authors           : Daniel Mokhtari
4
# credits           : Craig Markin
5
# date              : 20180615
6
# version update    : 20191001
7
# version           : 0.1.0
8
# usage             : With permission from DM
9
# python_version    : 3.7
10

11
# General Python
12
import os
4✔
13
import logging
4✔
14
from glob import glob
4✔
15
from pathlib import Path
4✔
16
from collections import namedtuple, OrderedDict
4✔
17
import pandas as pd
4✔
18

19
from tqdm import tqdm
4✔
20
from skimage import io
4✔
21
import re
4✔
22
from typing import Callable, Optional
4✔
23

24
from htbam_analysis.processing.chip import ChipImage
4✔
25

26

27
class ChipSeries:
4✔
28
    def __init__(self, device, series_index, attrs=None):
4✔
29
        """
30
        Constructor for a ChipSeries object.
31

32
        Arguments:
33
            (experiment.Device) device:
34
            (int) series_index:
35
            (dict) attrs: arbitrary ChipSeries metdata
36

37
        Returns:
38
            Return
39

40
        """
41

42
        self.device = device  # Device object
×
43
        self.attrs = attrs  # general metadata fro the chip
×
44
        self.series_indexer = series_index
×
45
        self.description = description
×
46
        self.chips = {}
×
47
        self.series_root = None
×
48
        logging.debug("ChipSeries Created | {}".format(self.__str__()))
×
49

50
    def add_file(self, identifier, path, channel, exposure):
4✔
51
        """
52
        Adds a ChipImage of the image at path to the ChipSeries, mapped from the passed identifier.
53

54
        Arguments:
55
            (Hashable) identifier: a unique chip identifier
56
            (str) path: image file path
57
            (str) channel: imaging channel
58
            (int) exposure: imaging exposure time (ms)
59

60
        Returns:
61
            None
62

63
        """
64

65
        source = Path(path)
×
66
        chipParams = (self.device.corners, self.device.pinlist, channel, exposure)
×
67
        self.chips[identifier] = ChipImage(
×
68
            self.device, source, {self.series_indexer: identifier}, *chipParams
69
        )
70
        logging.debug("Added Chip | Root: {}/, ID: {}".format(source, identifier))
×
71

72
    def load_files(self, root, channel, exposure, indexes=None, custom_glob=None):
4✔
73
        """
74
        Loads indexed images from a directory as ChipImages.
75
        Image filename stems must be of the form *_index.tif.
76

77
        Arguments:
78
            (str) root: directory path containing images
79
            (str) channel: imaging channel
80
            (int) exposure: imaging exposure time (ms)
81
            (list | tuple) indexes: custom experimental inde
82

83
        Returns:
84
            None
85

86
        """
87

88
        self.series_root = root
×
89

90
        glob_pattern = "*BGSubtracted_StitchedImg*.tif"
×
91
        if custom_glob:
×
92
            glob_pattern = custom_glob
×
93

94
        if not indexes:
×
95
            r = Path(root)
×
96
            img_files = [
×
97
                i
98
                for i in list(r.glob(glob_pattern))
99
                if not "ChamberBorders" in i.stem or "Summary" in i.stem
100
            ]
101
            
102
            if len(img_files) < 1:
×
103
                raise ProcessingException(f"No images found! Looked in directory \"{root}\" for images that matched the pattern: \"{glob_pattern}\"")
×
104
            img_files = [img for img in img_files if img.parts[-1][0] != "."]
×
105
            img_paths = [Path(os.path.join(r.parent, img)) for img in img_files]
×
106
            pattern= re.compile("^([a-z|A-Z]*_){1,2}([0-9]*_){1,3}[0-9]*$")
×
107
            
108
            correct_setting_imgs = []
×
109

110
            for img in img_paths:
×
111
                if pattern.match(img.stem):
×
112
                    params = re.sub("^([A-Z|a-z]*_){1,2}", "", img.stem)
×
113
                    img_exposure = params.split("_")[0]
×
114
                    img_channel = params.split("_")[1]
×
115
            
116
                    if img_exposure == str(exposure) and img_channel == str(channel):
×
117
                        correct_setting_imgs.append(img)
×
118
                    else:
119
                        pass
×
120
                else:
121
                    raise ProcessingException(f"Malformed image name found \"{img.stem}\". Make sure any decimals in concentration are replaced with underscores.")
×
122
         
123
            
124
            record = {float(".".join(re.sub("^([A-Z|a-z]*_){1,2}", "", path.stem).split("_")[2:])): path for path in correct_setting_imgs}
×
125
            chipParams = (self.device.corners, self.device.pinlist, channel, exposure)
×
126
            self.chips = {
×
127
                identifier: ChipImage(
128
                    self.device, source, {self.series_indexer: identifier}, *chipParams
129
                )
130
                for identifier, source in record.items()
131
            }
132
            
133
            keys = list(self.chips.keys())
×
134
            print(keys)
×
135
            logging.debug("Loaded Series | Root: {}/, IDs: {}".format(root, keys))
×
136

137
    def summarize(self):
4✔
138
        """
139
        Summarize the ChipSeries as a Pandas DataFrame for button and/or chamber features
140
        identified in the chips contained.
141

142
        Arguments:
143
            None
144

145
        Returns:
146
            (pd.DataFrame) summary of the ChipSeries
147

148
        """
149

150
        summaries = []
×
151
        for i, r in self.chips.items():
×
152
            df = r.summarize()
×
153
            df[self.series_indexer] = i
×
154
            summaries.append(df)
×
155
        return pd.concat(summaries).sort_index()
×
156

157
    def map_from(self, reference, mapto_args={}):
4✔
158
        """
159
        Maps feature positions from a reference chip.ChipImage to each of the ChipImages in the series.
160
        Specific features can be mapped by passing the optional mapto_args to the underlying
161
        mapper.
162

163
        Arguments:
164
            (chip.ChipImage) reference: reference image (with found button and/or chamber features)
165
            (dict) mapto_args: dictionary of keyword arguments passed to ChipImage.mapto().
166

167
        Returns:
168
            None
169

170
        """
171

172
        for chip in tqdm(
×
173
            self.chips.values(),
174
            desc="Series <{}> Stamped and Mapped".format(self.description),
175
        ):
176
            chip.stamp()
×
177
            reference.mapto(chip, **mapto_args)
×
178

179
    def from_record():
4✔
180
        """
181
        TODO: Import imaging from a Stitching record.
182
        """
183
        return
×
184

185
    def _repr_pretty_(self, p, cycle=True):
4✔
186
        p.text("<{}>".format(self.device.__str__()))
×
187

188
    def save_summary(self, outPath=None):
4✔
189
        """
190
        Generates and exports a ChipSeries summary Pandas DataFrame as a bzip2 compressed CSV file.
191

192
        Arguments:
193
            (str) outPath: target directory for summary
194

195
        Returns:
196
            None
197

198
        """
199

200
        target = self.series_root
×
201
        if outPath:
×
202
            target = outPath
×
203
        df = self.summarize()
×
204
        fn = "{}_{}_{}.csv.bz2".format(
×
205
            self.device.dname, self.description, "ChipSeries"
206
        )
207
        df.to_csv(os.path.join(target, fn), compression="bz2")
×
208

209
    def save_summary_images(self, outPath=None, featuretype="chamber"):
4✔
210
        """
211
        Generates and exports a stamp summary image (chip stamps concatenated)
212

213
        Arguments:
214
            (str) outPath: user-define export target directory
215
            (str) featuretype: type of feature overlay ('chamber' | 'button')
216

217
        Returns:
218
            None
219

220
        """
221

222
        target_root = self.series_root
×
223
        if outPath:
×
224
            target_root = outPath
×
225
        target = os.path.join(target_root, "SummaryImages")  # Wrapping folder
×
226
        os.makedirs(target, exist_ok=True)
×
227
        for c in self.chips.values():
×
228
            image = c.summary_image(featuretype)
×
229
            name = "{}_{}.tif".format("Summary", c.data_ref.stem)
×
230
            outDir = os.path.join(target, name)
×
231
            io.imsave(outDir, image, plugin="tifffile")
×
232
        logging.debug("Saved Summary Images | Series: {}".format(self.__str__()))
×
233

234
    def _delete_stamps(self):
4✔
235
        """
236
        Deletes and forces garbage collection of stamps for all ChipImages
237

238
        Arguments:
239
            None
240

241
        Returns:
242
            None
243

244
        """
245

246
        for c in self.chips.values():
×
247
            c._delete_stamps()
×
248

249
    def repo_dump(self, target_root, title, as_ubyte=False, featuretype="button"):
4✔
250
        """
251
        Save the chip stamp images to the target_root within folders title by chamber IDs
252

253
        Arguments:
254
            (str) target_root:
255
            (str) title:
256
            (bool) as_ubyte:
257

258
        Returns:
259
            None
260

261
        """
262

263
        for i, c in self.chips.items():
×
264
            title = "{}{}_{}".format(self.device.setup, self.device.dname, i)
×
265
            c.repo_dump(featuretype, target_root, title, as_ubyte=as_ubyte)
×
266

267
    def __str__(self):
4✔
268
        return "Description: {}, Device: {}".format(
×
269
            self.description, str((self.device.setup, self.device.dname))
270
        )
271

272

273
class StandardSeries(ChipSeries):
4✔
274
    def __init__(self, device, description, attrs=None):
4✔
275
        """
276
        Constructor for a StandardSeries object.
277

278
        Arguments:
279
            (experiment.Device) device: Device object
280
            (str) description: Terse description (e.g., 'cMU')
281
            (dict) attrs: arbitrary StandardSeries metadata
282

283
        Returns:
284
            None
285

286
        """
287

288
        self.device = device  # Device object
×
289
        self.attrs = attrs  # general metadata fro the chip
×
290
        self.series_indexer = "concentration_uM"
×
291
        self.description = description
×
292
        self.chips = None
×
293
        self.series_root = None
×
294
        logging.debug("StandardSeries Created | {}".format(self.__str__()))
×
295

296
    def get_hs_key(self):
4✔
297
        return max(self.chips.keys())
×
298

299
    def get_highstandard(self):
4✔
300
        """
301
        Gets the "maximal" (high standard) chip object key
302

303
        Arguments:
304
            None
305

306
        Returns:
307
            None
308

309
        """
310

311
        return self.chips[self.get_hs_key()]
×
312

313
    def map_from_hs(self, mapto_args={}):
4✔
314
        """
315
        Maps the chip image feature position from the StandardSeries high standard to each
316
        other ChipImage
317

318
        Arguments:
319
            (dict) mapto_args: dictionary of keyword arguments passed to ChipImage.mapto().
320

321
        Returns:
322
            None
323

324
        """
325

326
        reference_key = {self.get_hs_key()}
×
327
        all_keys = set(self.chips.keys())
×
328
        hs = self.get_highstandard()
×
329

330
        for key in tqdm(
×
331
            all_keys - reference_key,
332
            desc="Processing Standard <{}>".format(self.__str__()),
333
        ):
334
            self.chips[key].stamp()
×
335
            hs.mapto(self.chips[key], **mapto_args)
×
336

337
    def process(self, featuretype="chamber", coerce_center=False):
4✔
338
        """
339
        A high-level (script-like) function to execute analysis of a loaded Standard Series.
340
        Processes the high-standard (stamps and finds chambers) and maps processed high standard
341
        to each other ChipImage
342

343
        Arguments:
344
            (str) featuretype: stamp feature to map
345

346
        Returns:
347
            None
348

349
        """
350

351
        hs = self.get_highstandard()
×
352
        hs.stamp()
×
353
        hs.findChambers(coerce_center=coerce_center)
×
354
        self.map_from_hs(mapto_args={"features": featuretype})
×
355

356
    def process_summarize(self):
4✔
357
        """
358
        Simple wrapper to process and summarize the StandardSeries Data
359

360
        Arguments:
361
            None
362

363
        Returns:
364
            None
365

366
        """
367

368
        self.process()
×
369
        df = self.summarize()
×
370
        return df
×
371

372
    def save_summary(self, outPath=None):
4✔
373
        """
374
        Generates and exports a StandardSeries summary Pandas DataFrame as a bzip2 compressed CSV file.
375

376
        Arguments:
377
            (str | None) outPath: target directory for summary. If None, saves to the series root.
378

379
        Returns:
380
            None
381

382
        """
383

384
        target = self.series_root
×
385
        if outPath:
×
386
            target = outPath
×
387
        df = self.summarize()
×
388
        fn = "{}_{}_{}.csv.bz2".format(
×
389
            self.device.dname, self.description, "StandardSeries_Analysis"
390
        )
391
        df.to_csv(os.path.join(target, fn), compression="bz2")
×
392
        logging.debug(
×
393
            "Saved StandardSeries Summary | Series: {}".format(self.__str__())
394
        )
395

396

397
class Timecourse(ChipSeries):
4✔
398
    def __init__(self, device, description, attrs=None):
4✔
399
        """
400
        Constructor for a Timecourse object.
401

402
        Arguments:
403
            (experiment.Device) device:
404
            (str) description: user-define description
405
            (dict) attrs: arbitrary metadata
406

407
        Returns:
408
            None
409

410
        """
411

412
        self.device = device  # Device object
×
413
        self.attrs = attrs  # general metadata fro the chip
×
414
        self.description = description
×
415
        self.series_indexer = "time_s"
×
416
        self.chips = None
×
417
        self.series_root = None
×
418
        logging.debug("Timecourse Created | {}".format(self.__str__()))
×
419

420
    def process(self, reference, featuretype="chamber"):
4✔
421
        """
422
        Map chamber positions (stamp, feature mapping) from the provided reference
423

424
        Arguments:
425
            (ChipImage) chamber_reference: reference ChipImage for chamber or button position mapping
426
            (str) featuretype: type of feature to map ('chamber' | 'button' | 'all')
427

428
        Returns:
429
            None
430

431
        """
432

433
        self.map_from(reference, mapto_args={"features": featuretype})
×
434

435
    def process_summarize(self, reference):
4✔
436
        """
437

438
        Process (stamp, positions and features mapping) and summarize the resulting image data
439
        as a Pandas DataFrame
440

441
        Arguments:
442
            (ChipImage) reference: reference ChipImage for chamber ro button position mapping
443

444
        Returns:
445
            (pd.DataFrame) DataFrame of chip feature information
446

447
        """
448

449
        self.process(reference)
×
450
        df = self.summarize()
×
451
        return df
×
452

453
    def save_summary(self, outPath=None):
4✔
454
        """
455

456
        Arguments:
457
            (str) outPath: target directory for summary
458

459
        Returns:
460
            None
461

462
        """
463

464
        target = self.series_root
×
465
        if outPath and os.isdir(outPath):
×
466
            target = outPath
×
467
        df = self.summarize()
×
468
        fn = "{}_{}_{}.csv.bz2".format(
×
469
            self.device.dname, self.description, "Timecourse"
470
        )
471
        df.to_csv(os.path.join(target, fn), compression="bz2")
×
472
        logging.debug(
×
473
            "Saved Timecourse Summary | Timecourse: {}".format(self.__str__())
474
        )
475

476

477
class Titration(ChipSeries):
4✔
478
    # TODO
479
    pass
4✔
480

481

482
class ChipQuant:
4✔
483
    def __init__(self, device, description, attrs=None):
4✔
484
        """
485
        Constructor for a ChipQuant object
486

487
        Arguments:
488
            (experiment.Device) device: device object
489
            (str) description: terse user-define description
490
            (dict) attrs: arbitrary metadata
491

492
        Returns:
493
            None
494

495
        """
496

497
        self.device = device
4✔
498
        self.description = description
4✔
499
        self.attrs = attrs
4✔
500
        self.chip = None
4✔
501
        self.processed = False
4✔
502
        logging.debug("ChipQuant Created | {}".format(self.__str__()))
4✔
503

504
    def load_file(self, path, channel, exposure):
4✔
505
        """
506
        Loads an image file as a ChipQuant.
507

508
        Arguments:
509
            (str) path: path to image
510
            (str) channel: imaging channel
511
            (int) exposure: exposure time (ms)
512

513
        Returns:
514
            None
515

516
        """
517

518
        p = Path(path)
4✔
519
        chipParams = (self.device.corners, self.device.pinlist, channel, exposure)
4✔
520
        self.chip = ChipImage(self.device, p, {}, *chipParams)
4✔
521
        logging.debug("ChipQuant Loaded | Description: {}".format(self.description))
4✔
522

523
    def process(self, reference=None, mapped_features="button", coerce_center=False):
4✔
524
        """
525
        Processes a chip quantification by stamping and finding buttons. If a reference is passed,
526
        button positions are mapped.
527

528
        Arguments:
529
            (ChipImage) button_ref: Reference ChipImage
530
            (st) mapped_features: features to map from the reference (if button_ref)
531

532
        Returns:
533
            None
534

535
        """
536

537
        self.chip.stamp()
4✔
538
        if not reference:
4✔
539
            if mapped_features == "button":
4✔
540
                self.chip.findButtons()
4✔
541
            elif mapped_features == "chamber":
4✔
542
                self.chip.findChambers(coerce_center=coerce_center)
4✔
543
            elif mapped_features == "all":
×
544
                self.chip.findButtons()
×
545
                self.chip.findChambers(coerce_center=coerce_center)
×
546
            else:
547
                raise ValueError(
×
548
                    'Must specify valid feature name to map ("button", "chamber", or "all"'
549
                )
550
        else:
551
            reference.mapto(self.chip, features=mapped_features)
×
552
        self.processed = True
4✔
553
        logging.debug("Features Processed | {}".format(self.__str__()))
4✔
554

555
    def summarize(self):
4✔
556
        """
557
        Summarize the ChipQuant as a Pandas DataFrame for button features
558
        identified in the chips contained.
559

560
        Arguments:
561
            None
562

563
        Returns:
564
            (pd.DataFrame) summary of the ChipSeries
565

566
        """
567

568
        if self.processed:
4✔
569
            return self.chip.summarize()
4✔
570
        else:
571
            raise ValueError("Must first process ChipQuant")
×
572

573
    def process_summarize(self, reference=None, process_kwrds={}):
4✔
574
        """
575
        Script-like wrapper for process() and summarize() methods
576

577
        Arguments:
578
            (chip.ChipImage) reference: ChipImage to use as a reference
579
            (dict) process_kwrds: keyword arguments passed to ChipQuant.process()
580

581
        Returns:
582
            (pd.DataFrame) summary of the ChipSeries
583

584

585
        """
586
        self.process(reference=reference, **process_kwrds)
×
587
        return self.summarize()
×
588

589
    def save_summary_image(self, outPath_root=None, feature_type="button"):
4✔
590
        """
591
        Generates and exports a stamp summary image (chip stamps concatenated)
592

593
        Arguments:
594
            (str) outPath_root: path of user-defined export root directory
595

596
        Returns:
597
            None
598

599
        """
600

601
        outPath = self.chip.data_ref.parent
×
602
        if outPath_root:
×
603
            if not os.isdir(outPath_root):
×
604
                em = "Export directory does not exist: {}".format(outPath_root)
×
605
                raise ValueError(em)
×
606
            outPath = Path(outPath_root)
×
607

608
        target = os.path.join(outPath, "SummaryImages")  # Wrapping folder
×
609
        os.makedirs(target, exist_ok=True)
×
610

611
        c = self.chip
×
612
        image = c.summary_image(feature_type)
×
613
        name = "{}_{}.tif".format("Summary", c.data_ref.stem)
×
614
        outDir = os.path.join(target, name)
×
615
        io.imsave(outDir, image, plugin="tifffile")
×
616
        logging.debug(
×
617
            "Saved ChipQuant Summary Image | ChipQuant: {}".format(self.__str__())
618
        )
619

620
    def repo_dump(self, outPath_root, as_ubyte=False):
4✔
621
        """
622
        Export the ChipQuant chip stamps to a repository (repo). The repo root contains a
623
        directory for each unique pinlist identifier (MutantID, or other) and subdirs
624
        for each chamber index. Stamps exported as .png
625

626
        Arguments:
627
            (str): outPath_root: path of user-defined repo root directory
628
            (bool) as_ubyte: flag to export the stamps as uint8 images
629

630
        Returns:
631
            None
632

633
        """
634

635
        title = "{}{}_{}".format(self.device.setup, self.device.dname, self.description)
×
636
        self.chip.repo_dump("button", outPath_root, title, as_ubyte=as_ubyte)
×
637

638
    def __str__(self):
4✔
639
        return "Description: {}, Device: {}".format(
4✔
640
            self.description, str((self.device.setup, self.device.dname))
641
        )
642

643

644
class Assay:
4✔
645
    def __init__(self, device, description, attrs=None):
4✔
646
        """
647
        Constructor for an Assay class.
648

649
        Arguments:
650
            (experiment.Device) device:
651
            (str) description: user-defined assay description
652
            (dict) attrs: arbitrary metadata
653

654
        Returns:
655
            None
656

657
        """
658

659
        self.device = device  # Device object
×
660
        self.attrs = attrs  # general metadata for the chip
×
661
        self.description = description
×
662
        self.series = None
×
663
        self.quants = []
×
664

665
    def add_series(self, c):
4✔
666
        """
667
        Setter to add an arbitary ChipSeries to the assay
668

669
        Arguments:
670
            (ChipSeries) c: a chipseries (or subclass)
671

672
        Returns:
673
            None
674

675
        """
676

677
        if isinstance(c, ChipSeries):
×
678
            self.series = c
×
679
        else:
680
            raise TypeError("Must provide a valid ChipSeries")
×
681

682
    def add_quant(self, c):
4✔
683
        """
684
        Setter to add an arbitry ChipQuant to the Assay.
685

686
        Arguments:
687
            (ChipQuant) c: a chipquant
688

689
        Returns:
690
            None
691

692

693
        """
694
        self.quants.append(c)
×
695

696

697
class TurnoverAssay(Assay):
4✔
698
    def merge_summarize(self):
4✔
699
        """
700
        A script-like method to summarize each quantification and join them with summary
701
        of the ChipSeries.
702

703
        Arguments:
704
            None
705

706
        Returns:
707
            (pd.DataFrame) a Pandas DataFrame summarizing the Assay
708

709
        """
710

711
        quants_cleaned = []
×
712
        for quant in self.quants:
×
713
            desc = quant.description
×
714
            summary = quant.chip.summarize()
×
715
            toAdd = summary.drop(columns=["id"])
×
716
            quants_cleaned.append(
×
717
                summary.add_suffix("_{}".format(desc.replace(" ", "_")))
718
            )
719

720
        kinSummary = self.series.summarize()
×
721
        merged = kinSummary.join(
×
722
            quants_cleaned, how="left", lsuffix="_kinetic", rsuffix="_buttonquant"
723
        )
724
        return merged
×
725

726

727
class AssaySeries:
4✔
728
    def __init__(
4✔
729
        self, device, descriptions, chamber_ref, button_ref, attrs=None, assays_attrs=[]
730
    ):
731
        """
732
        Constructor for and AssaySeries, a high-level class representing a collection of related TurnoverAssays.
733
        Holds arbitrary ordered TurnoverAssays as a dictionary. Designed specficially for eMITOMI use.
734
        TurnoverAssays are generated when the object is constructed, but must be populated after with
735
        kinetic and quantificationd data.
736

737
        Arguments:
738
            (experiment.Device) device: Device object
739
            (list | tuple) descriptions: Descriptions assocated with assays
740
            (chip.ChipImage) chamber_ref: a ChipImage object with found chambers for referencing
741
            (ChipQuant) button_ref: a ChipQuant object with found buttons for referencing
742
            (dict) attrs:arbitrary StandardSeries metadata
743

744
        Returns:
745
            None
746

747
        """
748

749
        self.device = device
×
750
        self.assays = OrderedDict(
×
751
            [
752
                (description, TurnoverAssay(device, description))
753
                for description in descriptions
754
            ]
755
        )
756
        self.chamber_ref = chamber_ref
×
757
        self.button_ref = button_ref
×
758
        self.chamber_root = None
×
759
        self.button_root = None
×
760
        self.attrs = attrs
×
761
        logging.debug("AssaySeries Created | {}".format(self.__str__()))
×
762
        logging.debug(
×
763
            "AssaySeries Chamber Reference Set | {}".format(chamber_ref.__str__())
764
        )
765
        logging.debug(
×
766
            "AssaySeries Button Reference Set | {}".format(button_ref.__str__())
767
        )
768

769
    def load_kin(self, descriptions, paths, channel, exposure, custom_glob=None):
4✔
770
        """
771
        Loads kinetic imaging and descriptions into the AssaySeries.
772

773
        Given paths of imaging root directories, creates Timecourse objects and associates with
774
        the passed descriptions. Descriptions and paths must be of equal length. Descriptions and
775
        paths are associated on their order (order matters)
776

777
        Arguments:
778
            (list | tuple) descriptions: descriptions of the imaging (paths)
779
            (list | tuple) paths: paths to directories containing timecourse imaging
780
            (str) channel: imaging channel
781
            (int) exposure: exposure time (ms)
782

783
        Returns:
784
            None
785

786
        """
787

788
        len_series = len(self.assays)
×
789
        len_descriptions = len(descriptions)
×
790

791
        if len_descriptions != len_series:
×
792
            raise ValueError(
×
793
                "Descriptions and series of different lengths. Number of assays and descriptions must match."
794
            )
795
        kin_refs = list(
×
796
            zip(descriptions, paths, [channel] * len_series, [exposure] * len_series)
797
        )
798
        for desc, p, chan, exp in kin_refs:
×
799
            t = Timecourse(self.device, desc)
×
800
            t.load_files(p, chan, exp, custom_glob=custom_glob)
×
801
            self.assays[desc].series = t
×
802

803
    def load_quants(self, descriptions, paths, channel, exposure):
4✔
804
        """
805
        Loads chip quantification imaging and associates with Timecourse data for existing Assay objects
806

807
        Arguments:
808
            (list | tuple) descriptions: descriptions of the imaging (paths)
809
            (list | tuple) paths: paths to directories containing quantification imaging
810
            (str) channel: imaging channel
811
            (int) exposure: exposure time (ms)
812

813
        Returns:
814
            None
815

816
        """
817

818
        if len(descriptions) != len(paths):
×
819
            raise ValueError("Descriptions and paths must be of same length")
×
820

821
        len_series = len(self.assays)
×
822

823
        if len(descriptions) == 1:
×
824
            descriptions = self.assays.keys()
×
825
            paths = list(paths) * len_series
×
826

827
        bq_refs = list(
×
828
            zip(descriptions, paths, [channel] * len_series, [exposure] * len_series)
829
        )
830
        for desc, p, chan, exp in bq_refs:
×
831
            q = ChipQuant(self.device, "Button_Quant")
×
832
            q.load_file(p, chan, exp)
×
833
            self.assays[desc].add_quant(q)
×
834

835
    def parse_kineticsFolders(
4✔
836
        self, root, file_handles, descriptors, channel, exposure, pattern=None, custom_glob=None
837
    ):
838
        """
839
        Walks down directory tree, matches the passed file handles to the Timecourse descriptors,
840
        and loads kinetic imaging data. Default pattern is "*_{}*/*/StitchedImages", with {}
841
        file_handle
842

843
        Arguments:
844
            (str) root: path to directory Three levels above the StitchedImages folders (dir
845
                above unique assay folders)
846
            (list | tuple) file_handles: unique file handles to match to dirs in the root.
847
            (list | tuple) descriptors: unique kinetic imaging descriptors, order-matched to
848
                the file_handles
849
            (str) channel: imaging channel
850
            (int) exposure: exposure time (ms)
851
            (bool) pattern: custom UNIX-style pattern to match when parsing dirs
852

853
        Returns:
854
            None
855

856
        """
857

858
        self.chamber_root = root
×
859
        if not pattern:
×
860
            pattern = "*_{}*/*/StitchedImages"
×
861

862
        p = lambda f: glob(os.path.join(root, pattern.format(f)))[0]
×
863
        files = {
×
864
            (handle, desc): p(handle) for handle, desc in zip(file_handles, descriptors)
865
        }
866
        
867
        self.load_kin(descriptors, files.values(), channel, exposure, custom_glob=custom_glob)
×
868

869
    def parse_quantificationFolders(
4✔
870
        self, root, file_handles, descriptors, channel, exposure, pattern=None
871
    ):
872
        """
873
        Walks down directory tree, matches the passed file handles to the ChipQuant descriptors,
874
        and loads button quantification imaging data. Default pattern is "*_{}*/*/StitchedImages/
875
        BGSubtracted_StitchedImg*.tif", with {} file_handle
876

877
        Arguments:
878
            (str) root: path to directory Three levels above the StitchedImages folders (dir
879
                above unique assay folders)
880
            (list | tuple) file_handles: unique file handles to match to dirs in the root.
881
            (list | tuple) descriptors: unique kinetic imaging descriptors, order-matched to
882
                the file_handles. MUST BE THE SAME USED FOR parse_kineticsFolders
883
            (str) channel: imaging channel
884
            (int) exposure: exposure time (ms)
885
            (bool) pattern: custom UNIX-style pattern to match when parsing dirs
886

887
        Returns:
888
            None
889

890
        """
891

892
        if not pattern:
×
893
            pattern = "*_{}*/*/StitchedImages/BGSubtracted_StitchedImg*.tif"
×
894

895
        try:
×
896
            p = lambda f: glob(os.path.join(root, pattern.format(f)))[0]
×
897
            files = {
×
898
                (handle, desc): p(handle)
899
                for handle, desc in zip(file_handles, descriptors)
900
            }
901
        except:
×
902
            raise ValueError(
×
903
                "Error parsing filenames for quantifications. Glob pattern is: {}".format(
904
                    pattern
905
                )
906
            )
907

908
        self.load_quants(descriptors, files.values(), channel, exposure)
×
909

910
    def summarize(self):
4✔
911
        """
912
        Summarizes an AssaySeries as a Pandas DataFrame.
913

914
        Arguments:
915
            None
916

917
        Returns:
918
            (pd.DataFrame) summary of the AssaySeries
919

920
        """
921

922
        summaries = []
×
923
        for tc in self.assays.values():
×
924
            s = tc.merge_summarize()
×
925
            s["series_index"] = tc.description
×
926
            summaries.append(s)
×
927
        return pd.concat(summaries).sort_index()
×
928

929
    def process_quants(self, subset=None):
4✔
930
        """
931
        Processes the chip quantifications and saves summary images for each of, or a subset of,
932
        the assays.
933

934
        Arguments:
935
            (list | tuple) subset: list of assay descriptors (a subset of the assay dictionary keys)
936

937
        Returns:
938
            None
939

940
        """
941

942
        if not subset:
×
943
            subset = self.assays.keys()
×
944
        for key in tqdm(subset, desc="Mapping and Processing Buttons"):
×
945
            for quant in self.assays[key].quants:
×
946
                quant.process(reference=self.button_ref, mapped_features="button")
×
947
                quant.save_summary_image()
×
948

949
    def process_kinetics(self, subset=None, low_mem=True):
4✔
950
        """
951
        Processes the timecourses and saves summary images for each of, or a subset of,
952
        the assays.
953

954
        Arguments:
955
            (list | tuple) subset: list of assay descriptors (a subset of the assay dictionary keys)
956
            (bool) low_mem: flag to delete and garbage collect stamp data of all ChipImages
957
                after summarization and export
958

959
        Returns:
960
            None
961

962
        """
963

964
        if not subset:
×
965
            subset = self.assays.keys()
×
966
        for key in subset:
×
967
            s = self.assays[key].series
×
968
            s.process(self.chamber_ref)
×
969
            s.save_summary()
×
970
            s.save_summary_images(featuretype="chamber")
×
971
            if low_mem:
×
972
                s._delete_stamps()
×
973

974
    def save_summary(self, description=None, outPath=None):
4✔
975
        """
976
        Saves a CSV summary of the AssaySeries to the specified path.
977

978
        Arguments:
979
            (str) outPath: path of directory to save summary
980

981
        Returns:
982
            None
983

984
        """
985

986
        if not outPath:
×
987
            outPath = self.chamber_root
×
988
        df = self.summarize()
×
989
        if not description:
×
990
            fn = "{}_{}.csv.bz2".format(self.device.dname, "TitrationSeries_Analysis")
×
991
        else:   
992
            fn = "{}_{}_{}.csv.bz2".format(self.device.dname, description, "TitrationSeries_Analysis")
×
993
        df.to_csv(os.path.join(outPath, fn), compression="bz2")
×
994

995
    def __str__(self):
4✔
996
        return "Assays: {}, Device: {}, Attrs: {}".format(
×
997
            list(self.assays.keys()),
998
            str((self.device.setup, self.device.dname)),
999
            self.attrs,
1000
        )
1001

1002

1003
class ButtonChamberAssaySeries:
4✔
1004
    # This class permits simultaneous kinetic imaging and analysis of chambers and buttons
1005
    # It consists of a collection of kinetic imaging (one or more timecourses) in one or more channels
1006

1007
    def __init__(
4✔
1008
        self,
1009
        device,
1010
        descriptions,
1011
        chamber_ref,
1012
        button_ref,
1013
        channels,
1014
        attrs=None,
1015
        assays_attrs=[],
1016
    ):
1017
        """ """
1018

1019
        self.device = device
×
1020
        self.channels = channels
×
1021
        self.assays = OrderedDict(
×
1022
            [
1023
                ((description, channel), TurnoverAssay(device, description))
1024
                for description in descriptions
1025
                for channel in channels
1026
            ]
1027
        )
1028
        self.chamber_ref = chamber_ref
×
1029
        self.button_ref = button_ref
×
1030
        self.root = None
×
1031
        self.attrs = attrs
×
1032
        logging.debug("AssaySeries Created | {}".format(self.__str__()))
×
1033
        logging.debug(
×
1034
            "AssaySeries Chamber Reference Set | {}".format(chamber_ref.__str__())
1035
        )
1036
        logging.debug(
×
1037
            "AssaySeries Button Reference Set | {}".format(button_ref.__str__())
1038
        )
1039

1040
    def parse_kineticsFolders(
4✔
1041
        self, root, file_handles, descriptors, channel, exposure, pattern=None
1042
    ):
1043
        """
1044
        Walks down directory tree, matches the passed file handles to the Timecourse descriptors,
1045
        and loads kinetic imaging data. Default pattern is "*_{}*/*/StitchedImages", with {}
1046
        file_handle
1047

1048
        Arguments:
1049
            (str) root: path to directory Three levels above the StitchedImages folders (dir
1050
                above unique assay folders)
1051
            (list | tuple) file_handles: unique file handles to match to dirs in the root.
1052
            (list | tuple) descriptors: unique kinetic imaging descriptors, order-matched to
1053
                the file_handles
1054
            (str) channel: imaging channel
1055
            (int) exposure: exposure time (ms)
1056
            (bool) pattern: custom UNIX-style pattern to match when parsing dirs
1057

1058
        Returns:
1059
            None
1060

1061
        """
1062

1063
        self.root = root
×
1064
        if not pattern:
×
1065
            pattern = "*_{}*/{}/StitchedImages"
×
1066

1067
        p = lambda f: glob(os.path.join(root, pattern.format(f, channel)))[0]
×
1068
        files = {
×
1069
            (handle, desc, channel): p(handle)
1070
            for handle, desc in zip(file_handles, descriptors)
1071
        }
1072

1073
        self.load_kin(descriptors, files.values(), channel, exposure)
×
1074

1075
    def load_kin(self, descriptions, paths, channel, exposure):
4✔
1076
        """
1077
        Loads kinetic imaging and descriptions into the ButtonChamberAssaySeries.
1078
            None
1079

1080
        """
1081

1082
        len_series = len(self.assays)
×
1083
        len_descriptions = len(descriptions)
×
1084

1085
        if len_descriptions != len_series:
×
1086
            raise ValueError(
×
1087
                "Descriptions and series of different lengths. Number of assays and descriptions must match."
1088
            )
1089
        kin_refs = list(
×
1090
            zip(descriptions, paths, [channel] * len_series, [exposure] * len_series)
1091
        )
1092
        for desc, p, chan, exp in kin_refs:
×
1093
            t = Timecourse(self.device, desc)
×
1094
            t.load_files(p, chan, exp)
×
1095
            self.assays[(desc, chan)].series = t
×
1096

1097
    def process_kinetics(
4✔
1098
        self,
1099
        subset=None,
1100
        featuretype="chamber",
1101
        save_summary=True,
1102
        save_images=True,
1103
        low_mem=True,
1104
    ):
1105
        """
1106
        Processes the timecourses and saves summary images for each of, or a subset of,
1107
        the assays.
1108

1109
        Arguments:
1110
            (list | tuple) subset: list of assay descriptors (a subset of the assay dictionary keys)
1111
            (bool) low_mem: flag to delete and garbage collect stamp data of all ChipImages
1112
                after summarization and export
1113

1114
        Returns:
1115
            None
1116

1117
        """
1118

1119
        if not subset:
×
1120
            subset = self.assays.keys()
×
1121
        for key in subset:
×
1122
            s = self.assays[key].series
×
1123
            if featuretype == "chamber":
×
1124
                try:
×
1125
                    s.process(self.chamber_ref)
×
1126
                except:
×
1127
                    raise ValueError(
×
1128
                        "No chamber ref provided (did you provice button ref instead?)"
1129
                    )
1130
            if featuretype == "button":
×
1131
                try:
×
1132
                    s.process(self.button_ref, featuretype='button')
×
1133
                except:
×
1134
                    raise ValueError(
×
1135
                        "No button ref provided (did you provice chamber ref instead?)"
1136
                    )
1137
            if save_summary:
×
1138
                s.save_summary()
×
1139
            if save_images:
×
1140
                s.save_summary_images(featuretype=featuretype)
×
1141
            if low_mem:
×
1142
                s._delete_stamps()
×
1143

1144
    def summarize(self):
4✔
1145
        """
1146
        Summarizes an ButtonChamberAssaySeries as a Pandas DataFrame.
1147

1148
        Arguments:
1149
            None
1150

1151
        Returns:
1152
            (pd.DataFrame) summary of the ButtonChamberAssaySeries
1153

1154
        """
1155

1156
        summaries = []
×
1157
        for tc in self.assays.values():
×
1158
            s = tc.merge_summarize()
×
1159
            s["series_index"] = tc.description
×
1160
            summaries.append(s)
×
1161
        return pd.concat(summaries).sort_index()
×
1162

1163
    def save_summary(self, outPath=None):
4✔
1164
        """
1165
        Saves a CSV summary of the ButtonChamberAssaySeries to the specified path.
1166

1167
        Arguments:
1168

1169
        Returns:
1170
            None
1171

1172
        """
1173

1174
        if not outPath:
×
1175
            outPath = self.root
×
1176
        df = self.summarize()
×
1177
        fn = "{}_{}.csv.bz2".format(
×
1178
            self.device.dname, "ButtonChamberAssaySeries_Analysis"
1179
        )
1180
        df.to_csv(os.path.join(outPath, fn), compression="bz2")
×
1181

1182
    def __str__(self):
4✔
1183
        return "Assays: {}, ..., Device: {}, Channels: {}".format(
×
1184
            list(self.assays.keys())[0], str((self.device.setup, self.device.dname)), str(self.channels)
1185
        )
1186

1187
    def _repr_pretty_(self, p, cycle=True):
4✔
1188
        p.text("<{}>".format(self.__str__()))
×
1189

1190

1191
class ButtonBindingSeries:
4✔
1192
    def __init__(
4✔
1193
            self,
1194
            device,
1195
            button_ref,
1196
            prey_channel: str,
1197
            prey_exposure: int,
1198
            bait_channel: str = '2',
1199
            bait_exposure: int = 5
1200
            ) -> None:
1201
        """Initializes a ButtonBindingSeries object.
1202

1203
        Args:
1204
            device: The imaging device used.
1205
            button_ref: A reference object containing chip information.
1206
            prey_channel (str): Channel used to detect prey.
1207
            prey_exposure (int): Exposure time for prey imaging.
1208
            bait_channel (str, optional): Channel used to detect bait. Defaults to '2'.
1209
            bait_exposure (int, optional): Exposure time for bait imaging. Defaults to 5.
1210
        
1211
        Returns:
1212
            None
1213
        """
1214
        self.device = device
4✔
1215
        self.button_ref = button_ref
4✔
1216
        self.prey_channel = prey_channel
4✔
1217
        self.prey_exposure = prey_exposure 
4✔
1218
        self.bait_channel = bait_channel
4✔
1219
        self.bait_exposure = bait_exposure
4✔
1220
    
1221
    def grab_binding_images(self, binding_path: str, verbose: bool=True, concentration_parser: Optional[Callable[[str], float]] = None):
4✔
1222
        """Grabs images from a directory structure for PreWash and PostWash conditions.
1223

1224
        Args:
1225
            binding_path (str): Root directory containing image data.
1226
            verbose (bool, optional): Whether to print paths to found images. Defaults to True.
1227
            concentration_parser (callable, optional): Function to extract concentration from file path.
1228
                If None, a default parser will be used.
1229

1230
        Returns:
1231
            None
1232
        """
1233

1234
        # utility function that globs images generated with a specified exposure, channel, etc
1235
        def get_images(parent_path: str, exposure: int, channel: int, postwash: bool = True):
4✔
1236
            wash_timing = 'PostWash' if postwash else 'PreWash'
4✔
1237
            handle = '*{wash_timing}_Quant/{channel}/StitchedImages/BGSubtracted_StitchedImg_{exp}_{channel}_0.tif'.format(
4✔
1238
                wash_timing=wash_timing,
1239
                channel=channel, 
1240
                exp=exposure
1241
                )
1242
            return glob(os.path.join(parent_path, handle))
4✔
1243
        
1244
        # default function for grabbing concentrations from filenames using regex  
1245
        def concentration_parser_default(file: str):
4✔
NEW
1246
            parent_path = binding_path
×
1247

1248
            # use regex to pull out note
NEW
1249
            handle = os.path.relpath(file, parent_path).split('/')[0]
×
NEW
1250
            note_match = re.search(r"\d{8}-\d{6}-d\d+_(.+?)_(PreWash|PostWash)_Quant", handle)
×
NEW
1251
            if not note_match:
×
NEW
1252
                raise ValueError(f"Could not extract note from file path: {file}")
×
1253
            
NEW
1254
            note = note_match.group(1)
×
1255

1256
            # then from note, grab the concentration
NEW
1257
            concentration_match = re.search(r"([^\W_]+)(?=[a-z]M)", note)
×
NEW
1258
            if not concentration_match:
×
NEW
1259
                raise ValueError(f"Could not extract concentration from note: {note}")
×
1260

1261
            # convert numeric string to a float
NEW
1262
            concentration = concentration_match.group(1)
×
NEW
1263
            concentration = float(concentration.replace('_', '.'))
×
1264

NEW
1265
            return concentration
×
1266
        
1267
        concentration_parser = concentration_parser if concentration_parser else concentration_parser_default
4✔
1268

1269

1270
        self.prewash_bait_images = get_images(binding_path, self.bait_exposure, self.bait_channel, postwash=False)
4✔
1271
        self.prewash_bait_concentrations = [concentration_parser(f) for f in self.prewash_bait_images]
4✔
1272

1273
        self.postwash_bait_images = get_images(binding_path, self.bait_exposure, self.bait_channel, postwash=True)
4✔
1274
        self.postwash_bait_concentrations = [concentration_parser(f) for f in self.postwash_bait_images]
4✔
1275

1276
        self.postwash_prey_images = get_images(binding_path, self.prey_exposure, self.prey_channel, postwash=True)
4✔
1277
        self.postwash_prey_concentrations = [concentration_parser(f) for f in self.postwash_prey_images]
4✔
1278

1279
        if not len(self.prewash_bait_images) == len(self.postwash_bait_images) == len(self.postwash_prey_images):
4✔
NEW
1280
            raise ValueError("The number of PreWash bait, PostWash bait, and PostWash prey images must be equal!")
×
1281

1282
        if verbose:
4✔
NEW
1283
            print('PREWASH BAIT IMAGES:\n' + '\n'.join([f'{i} / {c}' for i,c in zip(self.prewash_bait_images, self.prewash_bait_concentrations)]) + '\n')
×
NEW
1284
            print('POSTWASH BAIT IMAGES:\n' + '\n'.join([f'{i} / {c}' for i,c in zip(self.postwash_bait_images, self.postwash_bait_concentrations)]) + '\n')
×
NEW
1285
            print('POSTWASH PREY IMAGES:\n' + '\n'.join([f'{i} / {c}' for i,c in zip(self.postwash_prey_images, self.postwash_prey_concentrations)]))
×
1286

1287
    def process(
4✔
1288
            self, 
1289
            save_summary_images: bool = True
1290
            ) -> None:
1291
        """Processes binding images to quantify signal across concentrations.
1292

1293
        Args:
1294
            save_summary_images (bool, optional): Specifies whether or not summary images are saved.
1295

1296
        Returns:
1297
            None
1298

1299
        Raises:
1300
            ValueError: If the concentration cannot be extracted from the file name.
1301
        """
1302

1303
        # utility function for processing binding images
NEW
1304
        def process_binding_images(
×
1305
                images: str,
1306
                concentrations: list,
1307
                channel: int, 
1308
                exposure: int,
1309
                reference_device, 
1310
                reference_chip, 
1311
                ):
1312
            """Processes a set of images with known concentrations.
1313

1314
            Args:
1315
                images (str): List of image file paths.
1316
                concentrations (list): List of concentrations corresponding to each image.
1317
                channel (int): Imaging channel.
1318
                exposure (int): Exposure time.
1319
                reference_device: Device object used for reference.
1320
                reference_chip: Chip object used as a reference.
1321

1322
            Returns:
1323
                pd.DataFrame: Concatenated data from all processed images.
1324
            """
NEW
1325
            data = []
×
NEW
1326
            for f, c in tqdm(zip(images, concentrations), total=len(images)):
×
NEW
1327
                chip_quant = ChipQuant(reference_device, 'ButtonReference')
×
NEW
1328
                chip_quant.load_file(f, channel, exposure)
×
NEW
1329
                chip_quant.process(reference=reference_chip)
×
1330

NEW
1331
                if save_summary_images:
×
NEW
1332
                    chip_quant.save_summary_image()
×
1333

NEW
1334
                _data = chip_quant.summarize()
×
NEW
1335
                _data['concentration'] = [c] * len(_data)
×
NEW
1336
                data.append(_data)
×
1337

NEW
1338
            data = pd.concat(data)
×
NEW
1339
            data.sort_values(by=['x', 'y', 'concentration'], inplace=True)
×
NEW
1340
            return data
×
1341

NEW
1342
        print('Processing Pre-Wash Bait Images...')
×
NEW
1343
        self.prewash_bait_data = process_binding_images(
×
1344
            self.prewash_bait_images, 
1345
            self.prewash_bait_concentrations,
1346
            self.bait_channel,
1347
            self.bait_exposure, 
1348
            self.device,
1349
            self.button_ref.chip
1350
            )
1351

NEW
1352
        print('Processing Post-Wash Bait Images...')
×
NEW
1353
        self.postwash_bait_data = process_binding_images(
×
1354
            self.postwash_bait_images, 
1355
            self.postwash_bait_concentrations,
1356
            self.bait_channel,
1357
            self.bait_exposure, 
1358
            self.device,
1359
            self.button_ref.chip
1360
            )
1361

NEW
1362
        print('Processing Post-Wash Prey Images...')
×
NEW
1363
        self.postwash_prey_data = process_binding_images(
×
1364
            self.postwash_prey_images, 
1365
            self.postwash_prey_concentrations,
1366
            self.prey_channel,
1367
            self.prey_exposure, 
1368
            self.device,
1369
            self.button_ref.chip
1370
            )
1371

1372
    def save_summary(self, outpath: str, description: Optional[str] = None):
4✔
1373
        """Saves a CSV summary of the binding data to the specified path.
1374

1375
        Args:
1376
            outpath (str): Path to the directory where summary files should be saved.
1377
            description (str, optional): Optional description to include in the filenames.
1378

1379
        Returns:
1380
            None
1381
        """
NEW
1382
        if not description:
×
NEW
1383
            fn = "{dname}_TitrationSeries_Analysis".format(dname=self.device.dname)
×
1384
        else:   
NEW
1385
            fn = "{dname}_{desc}_TitrationSeries_Analysis".format(dname=self.device.dname, desc=description)
×
1386

NEW
1387
        self.prewash_bait_data.to_csv(os.path.join(outpath, fn + "_prewash_bait.csv.bz2"), compression="bz2", index=True)
×
NEW
1388
        self.postwash_bait_data.to_csv(os.path.join(outpath, fn + "_postwash_bait.csv.bz2"), compression="bz2", index=True)
×
NEW
1389
        self.postwash_prey_data.to_csv(os.path.join(outpath, fn + "_postwash_prey.csv.bz2"), compression="bz2", index=True)
×
1390

1391

1392
class ProcessingException(Exception):
4✔
1393
    pass
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc