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

oemof / oemof-solph / 6036014010

31 Aug 2023 10:14AM UTC coverage: 96.23%. First build
6036014010

push

github

web-flow
Merge pull request #969 from oemof/v0.5

Release v0.5.1

1263 of 1338 branches covered (0.0%)

Branch coverage included in aggregate %.

977 of 977 new or added lines in 26 files covered. (100.0%)

2566 of 2641 relevant lines covered (97.16%)

2.91 hits per line

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

89.86
/src/oemof/solph/processing.py
1
# -*- coding: utf-8 -*-
2

3
"""Modules for providing a convenient data structure for solph results.
3✔
4

5
Information about the possible usage is provided within the examples.
6

7
SPDX-FileCopyrightText: Uwe Krien <krien@uni-bremen.de>
8
SPDX-FileCopyrightText: Simon Hilpert
9
SPDX-FileCopyrightText: Cord Kaldemeyer
10
SPDX-FileCopyrightText: Stephan Günther
11
SPDX-FileCopyrightText: henhuy
12
SPDX-FileCopyrightText: Johannes Kochems
13

14
SPDX-License-Identifier: MIT
15

16
"""
17

18
import sys
3✔
19
from itertools import groupby
3✔
20

21
import numpy as np
3✔
22
import pandas as pd
3✔
23
from oemof.network.network import Entity
3✔
24
from pyomo.core.base.piecewise import IndexedPiecewise
3✔
25
from pyomo.core.base.var import Var
3✔
26

27
from .helpers import flatten
3✔
28

29

30
def get_tuple(x):
3✔
31
    """Get oemof tuple within iterable or create it
32

33
    Tuples from Pyomo are of type `(n, n, int)`, `(n, n)` and `(n, int)`.
34
    For single nodes `n` a tuple with one object `(n,)` is created.
35
    """
36
    for i in x:
3!
37
        if isinstance(i, tuple):
3✔
38
            return i
3✔
39
        elif issubclass(type(i), Entity):
3✔
40
            return (i,)
3✔
41

42
    # for standalone variables, x is used as identifying tuple
43
    if isinstance(x, tuple):
×
44
        return x
×
45

46

47
def get_timestep(x):
3✔
48
    """Get the timestep from oemof tuples
49

50
    The timestep from tuples `(n, n, int)`, `(n, n)`, `(n, int)` and (n,)
51
    is fetched as the last element. For time-independent data (scalars)
52
    zero ist returned.
53
    """
54
    if all(issubclass(type(n), Entity) for n in x):
3✔
55
        return 0
3✔
56
    else:
57
        return x[-1]
3✔
58

59

60
def remove_timestep(x):
3✔
61
    """Remove the timestep from oemof tuples
62

63
    The timestep is removed from tuples of type `(n, n, int)` and `(n, int)`.
64
    """
65
    if all(issubclass(type(n), Entity) for n in x):
3✔
66
        return x
3✔
67
    else:
68
        return x[:-1]
3✔
69

70

71
def create_dataframe(om):
3✔
72
    """Create a result DataFrame with all optimization data
73

74
    Results from Pyomo are written into one common pandas.DataFrame where
75
    separate columns are created for the variable index e.g. for tuples
76
    of the flows and components or the timesteps.
77
    """
78
    # get all pyomo variables including their block
79
    block_vars = list(
3✔
80
        set([bv.parent_component() for bv in om.component_data_objects(Var)])
81
    )
82
    var_dict = {}
3✔
83
    for bv in block_vars:
3✔
84
        # Drop the auxiliary variables introduced by pyomo's Piecewise
85
        parent_component = bv.parent_block().parent_component()
3✔
86
        if not isinstance(parent_component, IndexedPiecewise):
3✔
87
            try:
3✔
88
                idx_set = getattr(bv, "_index_set")
3✔
89
            except AttributeError:
×
90
                # To make it compatible with Pyomo < 6.4.1
91
                idx_set = getattr(bv, "_index")
×
92

93
            for i in idx_set:
3✔
94
                key = (str(bv).split(".")[0], str(bv).split(".")[-1], i)
3✔
95
                value = bv[i].value
3✔
96
                var_dict[key] = value
3✔
97

98
    # use this to create a pandas dataframe
99
    df = pd.DataFrame(list(var_dict.items()), columns=["pyomo_tuple", "value"])
3✔
100
    df["variable_name"] = df["pyomo_tuple"].str[1]
3✔
101

102
    # adapt the dataframe by separating tuple data into columns depending
103
    # on which dimension the variable/parameter has (scalar/sequence).
104
    # columns for the oemof tuple and timestep are created
105
    df["oemof_tuple"] = df["pyomo_tuple"].map(get_tuple)
3✔
106
    df = df[df["oemof_tuple"].map(lambda x: x is not None)]
3✔
107
    df["timestep"] = df["oemof_tuple"].map(get_timestep)
3✔
108
    df["oemof_tuple"] = df["oemof_tuple"].map(remove_timestep)
3✔
109

110
    # Use another call of remove timestep to get rid of period not needed
111
    df.loc[df["variable_name"] == "flow", "oemof_tuple"] = df.loc[
3✔
112
        df["variable_name"] == "flow", "oemof_tuple"
113
    ].map(remove_timestep)
114

115
    # order the data by oemof tuple and timestep
116
    df = df.sort_values(["oemof_tuple", "timestep"], ascending=[True, True])
3✔
117

118
    # drop empty decision variables
119
    df = df.dropna(subset=["value"])
3✔
120

121
    return df
3✔
122

123

124
def divide_scalars_sequences(df_dict, k):
3✔
125
    """Split results into scalars and sequences results
126

127
    Parameters
128
    ----------
129
    df_dict: dict
130
        dict of pd.DataFrames, keyed by oemof tuples
131
    k: tuple
132
        oemof tuple for results processing
133
    """
134
    try:
3✔
135
        condition = df_dict[k][:-1].isnull().any()
3✔
136
        scalars = df_dict[k].loc[:, condition].dropna().iloc[0]
3✔
137
        sequences = df_dict[k].loc[:, ~condition]
3✔
138
        return {"scalars": scalars, "sequences": sequences}
3✔
139
    except IndexError:
×
140
        error_message = (
×
141
            "Cannot access index on result data. "
142
            + "Did the optimization terminate"
143
            + " without errors?"
144
        )
145
        raise IndexError(error_message)
×
146

147

148
def set_result_index(df_dict, k, result_index):
3✔
149
    """Define index for results
150

151
    Parameters
152
    ----------
153
    df_dict: dict
154
        dict of pd.DataFrames, keyed by oemof tuples
155
    k: tuple
156
        oemof tuple for results processing
157
    result_index: pd.Index
158
        Index to use for results
159
    """
160
    try:
3✔
161
        df_dict[k].index = result_index
3✔
162
    except ValueError:
3✔
163
        try:
3✔
164
            df_dict[k] = df_dict[k][:-1]
3✔
165
            df_dict[k].index = result_index
3✔
166
        except ValueError as e:
3✔
167
            msg = (
3✔
168
                "\nFlow: {0}-{1}. This could be caused by NaN-values "
169
                "in your input data."
170
            )
171
            raise type(e)(
3✔
172
                str(e) + msg.format(k[0].label, k[1].label)
173
            ).with_traceback(sys.exc_info()[2])
174

175

176
def set_sequences_index(df, result_index):
3✔
177
    try:
3✔
178
        df.index = result_index
3✔
179
    except ValueError:
3✔
180
        try:
3✔
181
            df = df[:-1]
3✔
182
            df.index = result_index
3✔
183
        except ValueError:
×
184
            raise ValueError("Results extraction failed!")
×
185

186

187
def results(model, remove_last_time_point=False):
3✔
188
    """Create a nested result dictionary from the result DataFrame
189

190
    The already rearranged results from Pyomo from the result DataFrame are
191
    transferred into a nested dictionary of pandas objects.
192
    The first level key of that dictionary is a node (denoting the respective
193
    flow or component).
194

195
    The second level keys are "sequences" and "scalars" for a *standard model*:
196

197
    * A pd.DataFrame holds all results that are time-dependent, i.e. given as
198
      a sequence and can be indexed with the energy system's timeindex.
199
    * A pd.Series holds all scalar values which are applicable for timestep 0
200
      (i.e. investments).
201

202
    For a *multi-period model*, the second level key for "sequences" remains
203
    the same while instead of "scalars", the key "period_scalars" is used:
204

205
    * For sequences, see standard model.
206
    * Instead of a pd.Series, a pd.DataFrame holds scalar values indexed
207
      by periods. These hold investment-related variables.
208

209
    Examples
210
    --------
211
    * *Standard model*: `results[idx]['scalars']`
212
      and flows `results[n, n]['sequences']`.
213
    * *Multi-period model*: `results[idx]['period_scalars']`
214
      and flows `results[n, n]['sequences']`.
215

216
    Parameters
217
    ----------
218
    model : oemof.solph.BaseModel
219
        A solved oemof.solph model.
220
    remove_last_time_point : bool
221
        The last time point of all TIMEPOINT variables is removed to get the
222
        same length as the TIMESTEP (interval) variables without getting
223
        nan-values. By default, the last time point is removed if it has not
224
        been defined by the user in the EnergySystem but inferred. If all
225
        time points have been defined explicitly by the user the last time
226
        point will not be removed by default. In that case all interval
227
        variables will get one row with nan-values to have the same index
228
        for all variables.
229
    """
230
    # Extraction steps that are the same for both model types
231
    df = create_dataframe(model)
3✔
232

233
    # create a dict of dataframes keyed by oemof tuples
234
    df_dict = {
3✔
235
        k
236
        if len(k) > 1
237
        else (k[0], None): v[["timestep", "variable_name", "value"]]
238
        for k, v in df.groupby("oemof_tuple")
239
    }
240

241
    # Define index
242
    if model.es.timeindex is None:
3✔
243
        result_index = list(range(len(model.es.timeincrement) + 1))
3✔
244
    else:
245
        result_index = model.es.timeindex
3✔
246

247
    # create final result dictionary by splitting up the dataframes in the
248
    # dataframe dict into a series for scalar data and dataframe for sequences
249
    result = {}
3✔
250

251
    # Standard model results extraction
252
    if model.es.periods is None:
3✔
253
        result = _extract_standard_model_result(
3✔
254
            df_dict, result, result_index, remove_last_time_point
255
        )
256
        scalars_col = "scalars"
3✔
257

258
    # Results extraction for a multi-period model
259
    else:
260
        period_indexed = ["invest", "total", "old", "old_end", "old_exo"]
3✔
261

262
        result = _extract_multi_period_model_result(
3✔
263
            model,
264
            df_dict,
265
            period_indexed,
266
            result,
267
            result_index,
268
            remove_last_time_point,
269
        )
270
        scalars_col = "period_scalars"
3✔
271

272
    # add dual variables for bus constraints
273
    if model.dual is not None:
3✔
274
        grouped = groupby(
3✔
275
            sorted(model.BusBlock.balance.iterkeys()), lambda p: p[0]
276
        )
277
        for bus, timeindex in grouped:
3✔
278
            duals = [
3✔
279
                model.dual[model.BusBlock.balance[bus, p, t]]
280
                for _, p, t in timeindex
281
            ]
282
            if model.es.periods is None:
3✔
283
                df = pd.DataFrame({"duals": duals}, index=result_index[:-1])
3✔
284
            # TODO: Align with standard model
285
            else:
286
                df = pd.DataFrame({"duals": duals}, index=result_index)
3✔
287
            if (bus, None) not in result.keys():
3!
288
                result[(bus, None)] = {
3✔
289
                    "sequences": df,
290
                    scalars_col: pd.Series(dtype=float),
291
                }
292
            else:
293
                result[(bus, None)]["sequences"]["duals"] = duals
×
294

295
    return result
3✔
296

297

298
def _extract_standard_model_result(
3✔
299
    df_dict, result, result_index, remove_last_time_point
300
):
301
    """Extract and return the results of a standard model
302

303
    * Optionally remove last time point or include it elsewise.
304
    * Set index to timeindex and pivot results such that values are displayed
305
      for the respective variables. Reindex with the energy system's timeindex.
306
    * Filter for columns with nan values to retrieve scalar variables. Split
307
      up the DataFrame into sequences and scalars and return it.
308

309
    Parameters
310
    ----------
311
    df_dict : dict
312
        dictionary of results DataFrames
313
    result : dict
314
        dictionary to store the results
315
    result_index : pd.DatetimeIndex
316
        timeindex to use for the results (derived from EnergySystem)
317
    remove_last_time_point : bool
318
        if True, remove the last time point
319

320
    Returns
321
    -------
322
    result : dict
323
        dictionary with results stored
324
    """
325
    if remove_last_time_point:
3✔
326
        # The values of intervals belong to the time at the beginning of the
327
        # interval.
328
        for k in df_dict:
3✔
329
            df_dict[k].set_index("timestep", inplace=True)
3✔
330
            df_dict[k] = df_dict[k].pivot(
3✔
331
                columns="variable_name", values="value"
332
            )
333
            set_result_index(df_dict, k, result_index[:-1])
3✔
334
            result[k] = divide_scalars_sequences(df_dict, k)
3✔
335
    else:
336
        for k in df_dict:
3✔
337
            df_dict[k].set_index("timestep", inplace=True)
3✔
338
            df_dict[k] = df_dict[k].pivot(
3✔
339
                columns="variable_name", values="value"
340
            )
341
            # Add empty row with nan at the end of the table by adding 1 to the
342
            # last value of the numeric index.
343
            df_dict[k].loc[df_dict[k].index[-1] + 1, :] = np.nan
3✔
344
            set_result_index(df_dict, k, result_index)
3✔
345
            result[k] = divide_scalars_sequences(df_dict, k)
3✔
346

347
    return result
3✔
348

349

350
def _extract_multi_period_model_result(
3✔
351
    model,
352
    df_dict,
353
    period_indexed=None,
354
    result=None,
355
    result_index=None,
356
    remove_last_time_point=False,
357
):
358
    """Extract and return the results of a multi-period model
359

360
    Difference to standard model is in the way, scalar values are extracted
361
    since they now depend on periods.
362

363
    Parameters
364
    ----------
365
    model : oemof.solph.models.Model
366
        The optimization model
367
    df_dict : dict
368
        dictionary of results DataFrames
369
    period_indexed : list
370
        list of variables that are indexed by periods
371
    result : dict
372
        dictionary to store the results
373
    result_index : pd.DatetimeIndex
374
        timeindex to use for the results (derived from EnergySystem)
375
    remove_last_time_point : bool
376
        if True, remove the last time point
377

378
    Returns
379
    -------
380
    result : dict
381
        dictionary with results stored
382
    """
383
    for k in df_dict:
3✔
384
        df_dict[k].set_index("timestep", inplace=True)
3✔
385
        df_dict[k] = df_dict[k].pivot(columns="variable_name", values="value")
3✔
386
        # Split data set
387
        period_cols = [
3✔
388
            col for col in df_dict[k].columns if col in period_indexed
389
        ]
390
        # map periods to their start years for displaying period results
391
        d = {
3✔
392
            key: val + model.es.periods[0].min().year
393
            for key, val in enumerate(model.es.periods_years)
394
        }
395
        period_scalars = df_dict[k].loc[:, period_cols].dropna()
3✔
396
        sequences = df_dict[k].loc[
3✔
397
            :, [col for col in df_dict[k].columns if col not in period_cols]
398
        ]
399
        if remove_last_time_point:
3!
400
            set_sequences_index(sequences, result_index[:-1])
×
401
        else:
402
            set_sequences_index(sequences, result_index)
3✔
403
        if period_scalars.empty:
3✔
404
            period_scalars = pd.DataFrame(index=d.values())
3✔
405
        try:
3✔
406
            period_scalars.rename(index=d, inplace=True)
3✔
407
            period_scalars.index.name = "period"
3✔
408
            result[k] = {
3✔
409
                "period_scalars": period_scalars,
410
                "sequences": sequences,
411
            }
412
        except IndexError:
×
413
            error_message = (
×
414
                "Some indices seem to be not matching.\n"
415
                "Cannot properly extract model results."
416
            )
417
            raise IndexError(error_message)
×
418

419
    return result
3✔
420

421

422
def convert_keys_to_strings(result, keep_none_type=False):
3✔
423
    """
424
    Convert the dictionary keys to strings.
425

426
    All (tuple) keys of the result object e.g. results[(pp1, bus1)] are
427
    converted into strings that represent the object labels
428
    e.g. results[('pp1','bus1')].
429
    """
430
    if keep_none_type:
3✔
431
        converted = {
3✔
432
            tuple([str(e) if e is not None else None for e in k])
433
            if isinstance(k, tuple)
434
            else str(k)
435
            if k is not None
436
            else None: v
437
            for k, v in result.items()
438
        }
439
    else:
440
        converted = {
3✔
441
            tuple(map(str, k)) if isinstance(k, tuple) else str(k): v
442
            for k, v in result.items()
443
        }
444
    return converted
3✔
445

446

447
def meta_results(om, undefined=False):
3✔
448
    """
449
    Fetch some metadata from the Solver. Feel free to add more keys.
450

451
    Valid keys of the resulting dictionary are: 'objective', 'problem',
452
    'solver'.
453

454
    om : oemof.solph.Model
455
        A solved Model.
456
    undefined : bool
457
        By default (False) only defined keys can be found in the dictionary.
458
        Set to True to get also the undefined keys.
459

460
    Returns
461
    -------
462
    dict
463
    """
464
    meta_res = {"objective": om.objective()}
3✔
465

466
    for k1 in ["Problem", "Solver"]:
3✔
467
        k1 = k1.lower()
3✔
468
        meta_res[k1] = {}
3✔
469
        for k2, v2 in om.es.results[k1][0].items():
3✔
470
            try:
3✔
471
                if str(om.es.results[k1][0][k2]) == "<undefined>":
3✔
472
                    if undefined:
3!
473
                        meta_res[k1][k2] = str(om.es.results[k1][0][k2])
×
474
                else:
475
                    meta_res[k1][k2] = om.es.results[k1][0][k2]
3✔
476
            except TypeError:
×
477
                if undefined:
×
478
                    msg = "Cannot fetch meta results of type {0}"
×
479
                    meta_res[k1][k2] = msg.format(
×
480
                        type(om.es.results[k1][0][k2])
481
                    )
482

483
    return meta_res
3✔
484

485

486
def __separate_attrs(
3✔
487
    system, exclude_attrs, get_flows=False, exclude_none=True
488
):
489
    """
490
    Create a dictionary with flow scalars and series.
491

492
    The dictionary is structured with flows as tuples and nested dictionaries
493
    holding the scalars and series e.g.
494
    {(node1, node2): {'scalars': {'attr1': scalar, 'attr2': 'text'},
495
    'sequences': {'attr1': iterable, 'attr2': iterable}}}
496

497
    system:
498
        A solved oemof.solph.Model or oemof.solph.Energysystem
499
    exclude_attrs: List[str]
500
        List of additional attributes which shall be excluded from
501
        parameter dict
502
    get_flows: bool
503
        Whether to include flow values or not
504
    exclude_none: bool
505
        If set, scalars and sequences containing None values are excluded
506

507
    Returns
508
    -------
509
    dict
510
    """
511

512
    def detect_scalars_and_sequences(com):
3✔
513
        com_data = {"scalars": {}, "sequences": {}}
3✔
514

515
        default_exclusions = [
3✔
516
            "__",
517
            "_",
518
            "registry",
519
            "inputs",
520
            "outputs",
521
            "Label",
522
            "input",
523
            "output",
524
            "constraint_group",
525
        ]
526
        # Must be tuple in order to work with `str.startswith()`:
527
        exclusions = tuple(default_exclusions + exclude_attrs)
3✔
528
        attrs = [
3✔
529
            i
530
            for i in dir(com)
531
            if not (callable(getattr(com, i)) or i.startswith(exclusions))
532
        ]
533

534
        for a in attrs:
3✔
535
            attr_value = getattr(com, a)
3✔
536

537
            # Iterate trough investment and add scalars and sequences with
538
            # "investment" prefix to component data:
539
            if attr_value.__class__.__name__ == "Investment":
3✔
540
                invest_data = detect_scalars_and_sequences(attr_value)
3✔
541
                com_data["scalars"].update(
3✔
542
                    {
543
                        "investment_" + str(k): v
544
                        for k, v in invest_data["scalars"].items()
545
                    }
546
                )
547
                com_data["sequences"].update(
3✔
548
                    {
549
                        "investment_" + str(k): v
550
                        for k, v in invest_data["sequences"].items()
551
                    }
552
                )
553
                continue
3✔
554

555
            if isinstance(attr_value, str):
3✔
556
                com_data["scalars"][a] = attr_value
3✔
557
                continue
3✔
558

559
            # If the label is a tuple it is iterable, therefore it should be
560
            # converted to a string. Otherwise, it will be a sequence.
561
            if a == "label":
3✔
562
                attr_value = str(attr_value)
3✔
563

564
            # check if attribute is iterable
565
            # see: https://stackoverflow.com/questions/1952464/
566
            # in-python-how-do-i-determine-if-an-object-is-iterable
567
            try:
3✔
568
                _ = (e for e in attr_value)
3!
569
                com_data["sequences"][a] = attr_value
3✔
570
            except TypeError:
3✔
571
                com_data["scalars"][a] = attr_value
3✔
572

573
        com_data["sequences"] = flatten(com_data["sequences"])
3✔
574
        move_undetected_scalars(com_data)
3✔
575
        if exclude_none:
3✔
576
            remove_nones(com_data)
3✔
577

578
        com_data = {
3✔
579
            "scalars": pd.Series(com_data["scalars"]),
580
            "sequences": pd.DataFrame(com_data["sequences"]),
581
        }
582
        return com_data
3✔
583

584
    def move_undetected_scalars(com):
3✔
585
        for ckey, value in list(com["sequences"].items()):
3✔
586
            if isinstance(value, str):
3✔
587
                com["scalars"][ckey] = value
3✔
588
                del com["sequences"][ckey]
3✔
589
                continue
3✔
590
            try:
3✔
591
                _ = (e for e in value)
3!
592
            except TypeError:
×
593
                com["scalars"][ckey] = value
×
594
                del com["sequences"][ckey]
×
595
            else:
596
                try:
3✔
597
                    if not value.default_changed:
3!
598
                        com["scalars"][ckey] = value.default
3✔
599
                        del com["sequences"][ckey]
3✔
600
                except AttributeError:
3✔
601
                    pass
3✔
602

603
    def remove_nones(com):
3✔
604
        for ckey, value in list(com["scalars"].items()):
3✔
605
            if value is None:
3✔
606
                del com["scalars"][ckey]
3✔
607
        for ckey, value in list(com["sequences"].items()):
3✔
608
            if len(value) == 0 or value[0] is None:
3!
609
                del com["sequences"][ckey]
×
610

611
    # Check if system is es or om:
612
    if system.__class__.__name__ == "EnergySystem":
3✔
613
        components = system.flows() if get_flows else system.nodes
3✔
614
    else:
615
        components = system.flows if get_flows else system.es.nodes
3✔
616

617
    data = {}
3✔
618
    for com_key in components:
3✔
619
        component = components[com_key] if get_flows else com_key
3✔
620
        component_data = detect_scalars_and_sequences(component)
3✔
621
        comkey = com_key if get_flows else (com_key, None)
3✔
622
        data[comkey] = component_data
3✔
623
    return data
3✔
624

625

626
def parameter_as_dict(system, exclude_none=True, exclude_attrs=None):
3✔
627
    """
628
    Create a result dictionary containing node parameters.
629

630
    Results are written into a dictionary of pandas objects where
631
    a Series holds all scalar values and a dataframe all sequences for nodes
632
    and flows.
633
    The dictionary is keyed by flows (n, n) and nodes (n, None), e.g.
634
    `parameter[(n, n)]['sequences']` or `parameter[(n, n)]['scalars']`.
635

636
    Parameters
637
    ----------
638
    system: energy_system.EnergySystem
639
        A populated energy system.
640
    exclude_none: bool
641
        If True, all scalars and sequences containing None values are excluded
642
    exclude_attrs: Optional[List[str]]
643
        Optional list of additional attributes which shall be excluded from
644
        parameter dict
645

646
    Returns
647
    -------
648
    dict: Parameters for all nodes and flows
649
    """
650

651
    if exclude_attrs is None:
3✔
652
        exclude_attrs = []
3✔
653

654
    flow_data = __separate_attrs(
3✔
655
        system, exclude_attrs, get_flows=True, exclude_none=exclude_none
656
    )
657
    node_data = __separate_attrs(
3✔
658
        system, exclude_attrs, get_flows=False, exclude_none=exclude_none
659
    )
660

661
    flow_data.update(node_data)
3✔
662
    return flow_data
3✔
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

© 2025 Coveralls, Inc