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

KarlNaumann / MacroStat / 17580505858

09 Sep 2025 11:03AM UTC coverage: 96.722% (-0.6%) from 97.336%
17580505858

Pull #48

github

web-flow
Merge acef97386 into 8b571c6fc
Pull Request #48: Autograd: gradient passthrough

239 of 241 branches covered (99.17%)

Branch coverage included in aggregate %.

41 of 50 new or added lines in 3 files covered. (82.0%)

4 existing lines in 1 file now uncovered.

1561 of 1620 relevant lines covered (96.36%)

0.96 hits per line

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

95.18
/src/macrostat/core/variables.py
1
"""
2
A class for handling variables for a MacroStat model.
3
"""
4

5
__author__ = ["Karl Naumann-Woleske"]
1✔
6
__credits__ = ["Karl Naumann-Woleske"]
1✔
7
__license__ = "MIT"
1✔
8
__maintainer__ = ["Karl Naumann-Woleske"]
1✔
9

10
import json
1✔
11
import logging
1✔
12
import os
1✔
13
import re
1✔
14

15
import pandas as pd
1✔
16
import torch
1✔
17
from typing_extensions import Self
1✔
18

19
from macrostat.core.parameters import Parameters
1✔
20

21
logger = logging.getLogger(__name__)
1✔
22

23

24
class Variables:
1✔
25
    """Variables class for the MacroStat model.
26

27
    This class contains the variables of a MacroStat model, specifically the
28
    output tensors from the simulation. Furthermore, it contains the methods
29
    to export the variables to different formats, and holds important information
30
    on the characteristics of each of the variables, such as their dimension,
31
    long-form name, unit, description and notation.
32
    """
33

34
    def __init__(
1✔
35
        self,
36
        variable_info: dict | None = None,
37
        timeseries: dict | None = None,
38
        parameters: Parameters | dict | None = None,
39
        *args,
40
        **kwargs,
41
    ):
42
        """Initialize the variables for the model. If no variables are provided,
43
        the default variables will be used, and if only some variables are
44
        provided, the missing variables will be set to their default values.
45

46
        Parameters
47
        ----------
48
        variable_info: dict | None
49
            The variable information to use for the model.
50
        timeseries: dict | None
51
            The timeseries to use for the model.
52
        parameters: dict | None
53
            The parameters to use for the model.
54
        """
55
        if parameters is not None:
1✔
56
            self.parameters = parameters
1✔
57
        else:
58
            self.parameters = Parameters()
1✔
59

60
        self.tensor_kwargs = {
1✔
61
            k: self.parameters[k] for k in ["device", "requires_grad"]
62
        }
63

64
        if variable_info is None:
1✔
65
            self.info = self.get_default_variables()
1✔
66
        else:
67
            self.info = variable_info
1✔
68

69
        self.initialize_tensors()
1✔
70
        if timeseries is not None:
1✔
71
            self._timeseries_tensor_to_list(timeseries)
1✔
72
            self.gather_timeseries()
1✔
73

74
    ############################################################################
75
    # Accounting Functions
76
    ############################################################################
77

78
    def get_stock_variables(self):
1✔
79
        """Get all the stock variables from the info dictionary. Stock variables
80
        are those that are assets or liabilities, i.e. their "sfc" tuple starts
81
        with "asset" or "liability".
82
        """
83
        return {
1✔
84
            k: v["sfc"]
85
            for k, v in self.info.items()
86
            if v["sfc"][0][0].lower() in ["asset", "liability"]
87
        }
88

89
    def get_flow_variables(self):
1✔
90
        """Get all the flow variables from the info dictionary. Flow variables
91
        are those that are flows between sectors i.e. their "sfc" tuple starts
92
        with "inflow" or "outflow".
93
        """
94
        return {
1✔
95
            k: v["sfc"]
96
            for k, v in self.info.items()
97
            if v["sfc"][0][0].lower() in ["inflow", "outflow"]
98
        }
99

100
    def get_index_variables(self):
1✔
101
        """Get all the index variables from the info dictionary. Index variables
102
        are those that are indices, i.e. their "sfc" tuple starts with "index".
103
        """
104
        return {
1✔
105
            k: v["sfc"]
106
            for k, v in self.info.items()
107
            if v["sfc"][0][0].lower() == "index"
108
        }
109

110
    def balance_sheet_theoretical(
1✔
111
        self,
112
        mathfmt: str = "sphinx",
113
        non_camel_case: bool = False,
114
    ):
115
        """Calculate the theoretical balance sheet of the model based on the
116
        information in the info dictionary.
117

118
        Parameters
119
        ----------
120
        mathfmt: str
121
            The format to use for the math. Can be "sphinx", "myst", or "latex".
122
        non_camel_case: bool
123
            Whether to convert variable names to non-camel case.
124

125
        Returns
126
        -------
127
        pd.DataFrame
128
            A DataFrame containing the theoretical balance sheet of the model.
129
        """
130
        if not self.verify_sfc_info():
1✔
131
            raise ValueError("SFC information is not complete")
1✔
132

133
        bs = {}
1✔
134
        for k, v in self.get_stock_variables().items():
1✔
135
            for kind, sector in v:
1✔
136
                # Set the default balance sheet section to "Current"
137
                if isinstance(sector, list):
1✔
138
                    sector = tuple(sector)
1✔
139
                elif not isinstance(sector, tuple):
1✔
140
                    sector = (sector, "Current")
1✔
141

142
                # Convert variable name to non-camel case if requested
143
                item = k.replace(sector[0], "").replace(sector[0].lower(), "")
1✔
144
                if non_camel_case:
1✔
145
                    item = re.sub(r"([A-Z])", r" \1", item).strip()
1✔
146

147
                if kind.lower() == "asset":
1✔
148
                    notation = f"+{self.info[k]['notation']}"
1✔
149
                else:
150
                    # Only other option is that kind.lower() == "liability":
151
                    notation = f"-{self.info[k]['notation']}"
1✔
152

153
                # Add the item to the balance sheet
154
                if item not in bs:
1✔
155
                    bs[item] = {sector: notation}
1✔
156
                else:
157
                    bs[item][sector] = notation
1✔
158

159
        if len(bs) > 0:
1✔
160
            order = list(bs.keys())
1✔
161
            # Generate the balance sheet (in order of stocks)
162
            bs = pd.DataFrame.from_dict(bs, orient="index")
1✔
163
            bs = bs.loc[order]
1✔
164

165
            # Add columns for any other sectors that are not in the sfc
166
            for sector in self.parameters.hyper["sectors"]:
1✔
167
                if sector not in bs.columns:
1✔
168
                    bs[(sector, "Current")] = None
1✔
169
        else:
170
            # If there are no stocks, create a DataFrame with the sectors and Current
171
            bs = pd.DataFrame(
1✔
172
                columns=pd.MultiIndex.from_product(
173
                    [self.parameters.hyper["sectors"], ["Current"]]
174
                )
175
            )
176

177
        # Sort the columns by the order of the sectors
178
        bs = bs[self.parameters.hyper["sectors"]]
1✔
179

180
        # Apply the math format
181
        bs = self._apply_math_format(bs, mathfmt)
1✔
182

183
        # Add the total column to the end
184
        bs["Total"] = 0
1✔
185

186
        return bs
1✔
187

188
    def balance_sheet_actual(self):
1✔
189
        """Calculate the actual balance sheet of the model."""
190
        raise NotImplementedError("Not implemented yet")
191

192
    def transaction_matrix_theoretical(
1✔
193
        self,
194
        mathfmt: str = "sphinx",
195
        non_camel_case: bool = False,
196
    ):
197
        """Calculate the theoretical transaction matrix of the model based on the
198
        information in the info dictionary.
199

200
        Parameters
201
        ----------
202
        mathfmt: str
203
            The format to use for the math. Can be "sphinx", "myst", or "latex".
204
        non_camel_case: bool
205
            Whether to convert variable names to non-camel case.
206

207
        Returns
208
        -------
209
        pd.DataFrame
210
            A DataFrame containing the theoretical balance sheet of the model.
211
        """
212
        if not self.verify_sfc_info():
1✔
213
            raise ValueError("SFC information is not complete")
1✔
214

215
        tm = {}
1✔
216
        # Capture the flows
217
        for k, v in self.get_flow_variables().items():
1✔
218
            for kind, sector in v:
1✔
219
                sector = self._convert_sector_to_tuples(sector)
1✔
220

221
                # Convert variable name to non-camel case if requested
222
                if non_camel_case:
1✔
223
                    item = re.sub(r"([A-Z])", r" \1", k).strip()
1✔
224
                else:
225
                    item = k
1✔
226

227
                # Add item to the transaction matrix
228
                if kind.lower() == "inflow":
1✔
229
                    notation = f"+{self.info[k]['notation']}"
1✔
230
                else:
231
                    # Only other option is that kind.lower() == "outflow":
232
                    notation = f"-{self.info[k]['notation']}"
1✔
233

234
                # Add the item to the transaction matrix
235
                if item not in tm:
1✔
236
                    tm[item] = {sector: notation}
1✔
237
                else:
238
                    tm[item][sector] = notation
×
239

240
        # Capture the change in stocks
241
        for k, v in self.get_stock_variables().items():
1✔
242
            for kind, sector in v:
1✔
243
                sector = self._convert_sector_to_tuples(sector)
1✔
244

245
                # Change in wealth is not considered a flow
246
                if "wealth" in k.lower():
1✔
247
                    continue
1✔
248

249
                # Convert variable name to non-camel case if requested
250
                item = k.replace(sector[0], "").replace(sector[0].lower(), "")
1✔
251
                if non_camel_case:
1✔
252
                    item = re.sub(r"([A-Z])", r" \1", item).strip()
1✔
253

254
                item = f"Change in {item}"
1✔
255

256
                if kind.lower() == "asset":
1✔
257
                    notation = f"+{self.info[k]['notation']}"
1✔
258
                else:
259
                    # Only other option is that kind.lower() == "liability":
260
                    notation = f"-{self.info[k]['notation']}"
1✔
261

262
                # Add the item to the transaction matrix
263
                if item not in tm:
1✔
264
                    tm[item] = {sector: notation}
1✔
265
                else:
266
                    tm[item][sector] = notation
1✔
267

268
        # Maintain the order of flows then changes in stocks
269
        order = list(tm.keys())
1✔
270
        tm = pd.DataFrame.from_dict(tm, orient="index")
1✔
271
        tm = tm.loc[order]
1✔
272

273
        # Add columns for any other sectors that are not in the sfc
274
        for sector in self.parameters.hyper["sectors"]:
1✔
275
            if sector not in tm.columns:
1✔
276
                tm[(sector, "Current")] = None
1✔
277

278
        # Sort the columns by the order of the sectors
279
        tm = tm.loc[:, self.parameters.hyper["sectors"]]
1✔
280

281
        # Add the total column to the end
282
        tm["Total"] = 0
1✔
283

284
        # Add total row
285
        tm.loc["Total"] = 0
1✔
286

287
        # Apply the math format
288
        tm = self._apply_math_format(tm, mathfmt)
1✔
289

290
        return tm
1✔
291

292
    def transaction_matrix_actual(self):
1✔
293
        """Calculate the actual transaction matrix of the model."""
294
        raise NotImplementedError("Not implemented yet")
295

296
    ############################################################################
297
    # Comparison Functions
298
    ############################################################################
299

300
    def compare(self, other: Self | pd.DataFrame):
1✔
301
        """Compare the variables to another Variables object or DataFrame.
302

303
        Parameters
304
        ----------
305
        other: pd.DataFrame
306
            The DataFrame to compare the variables to.
307
        """
308
        if isinstance(other, Variables):
1✔
309
            other = other.to_pandas()
1✔
310

311
        df = self.to_pandas()
1✔
312

313
        # Compare columns and indices
314
        logger.info(f"Columns that don't match: {set(df.columns) - set(other.columns)}")
1✔
315
        logger.info(f"Indices that don't match: {set(df.index) - set(other.index)}")
1✔
316

317
        # Compare values
318
        diff = df.sub(other)
1✔
319
        rel_diff = df.sub(other).div(other).mul(100)
1✔
320
        rel_diff = rel_diff[other != 0]
1✔
321

322
        return diff, rel_diff
1✔
323

324
    ############################################################################
325
    # IO Functions
326
    ############################################################################
327

328
    @classmethod
1✔
329
    def from_excel(cls, file_path: os.PathLike, *args, **kwargs):
1✔
330
        """Initialize the variables from an Excel file.
331

332
        Parameters
333
        ----------
334
        file_path: os.PathLike
335
            The path to the Excel file to read the variables from.
336
        """
337
        raise NotImplementedError("Not implemented yet")
338

339
    @classmethod
1✔
340
    def from_json(cls, file_path: os.PathLike, *args, **kwargs):
1✔
341
        """Read the timeseries from a JSON file.
342

343
        Parameters
344
        ----------
345
        file_path: os.PathLike
346
            The path to the JSON file to read the timeseries from.
347
        """
348
        with open(file_path, "r") as file:
1✔
349
            data = json.load(file)
1✔
350
        varinfo = {k: v["info"] for k, v in data.items()}
1✔
351
        timeseries = {k: torch.tensor(v["timeseries"]) for k, v in data.items()}
1✔
352
        return cls(variable_info=varinfo, timeseries=timeseries)
1✔
353

354
    def to_excel(self, file_path: os.PathLike):
1✔
355
        """Convert the variables to an Excel file.
356

357
        Parameters
358
        ----------
359
        file_path: os.PathLike
360
            The path to the Excel file to save the variables to.
361
        """
362
        raise NotImplementedError("Not implemented yet")
363

364
    def to_json(self, file_path: os.PathLike):
1✔
365
        """Convert the parameters to a JSON file.
366

367
        Parameters
368
        ----------
369
        file_path: os.PathLike
370
            The path to the JSON file to save the timeseries to.
371
        """
372
        dicts = {
1✔
373
            k: {"info": self.info[k], "timeseries": v.tolist()}
374
            for k, v in self.gather_timeseries().items()
375
        }
376
        with open(file_path, "w") as file:
1✔
377
            json.dump(dicts, file)
1✔
378

379
    def to_pandas(self):
1✔
380
        """Convert the variables to a pandas DataFrame."""
381
        # Copy deep so we can delete/add without affecting core var
382
        timeseries = self.gather_timeseries()
1✔
383

384
        # Flatten matrix variables: a timeseries per row of the matrix
385
        for k, v in self.timeseries.items():
1✔
386
            if "matrix" in self.info[k]:
1✔
387
                del timeseries[k]
×
388
                for i, subvar in enumerate(self.info[k]["matrix"]):
×
389
                    key = f"{k}{subvar}"
×
390
                    timeseries[key] = v[:, i, :]
×
391

392
        df = pd.concat({k: pd.DataFrame(v) for k, v in timeseries.items()}, axis=1)
1✔
393
        return df
1✔
394

395
    def info_to_csv(self, file_path: str, sphinx_math: bool = False):
1✔
396
        """Convert the variables information to a CSV file.
397

398
        Parameters
399
        ----------
400
        file_path: str
401
            The path to the CSV file to save the variables information to.
402
        sphinx_math: bool
403
            Whether to add a ":math:" marker to the notation column, e.g. for
404
            usage in the documentation
405
        """
406
        df = pd.DataFrame.from_dict(self.info, orient="index")
1✔
407
        df["sectors"] = df["sectors"].apply(lambda x: ", ".join(x))
1✔
408
        df["history"] = df["history"].astype(int)
1✔
409
        if sphinx_math:
1✔
410
            df["notation"] = df["notation"].apply(lambda x: r":math:`" + x + r"`")
1✔
411
        df.columns = [i.title() for i in df.columns]
1✔
412
        df.to_csv(file_path)
1✔
413

414
    ############################################################################
415
    # General Functions
416
    ############################################################################
417

418
    def check_health(self):
1✔
419
        """Check the health of the variables. This is where the user may want to
420
        implement checks for consistency of the variables, e.g. whether the
421
        balance sheet is in balance, or whether the redundant equations hold.
422

423
        By default, this function returns True, indicating that the variables
424
        are healthy. This is to facilitate usage of the variables object in other
425
        functions.
426
        """
427
        logger.warning("Check health not implemented for this model")
1✔
428
        return True
1✔
429

430
    def get_default_variables(self):
1✔
431
        """Return the default variables information dictionary.
432

433
        This function returns a dictionary of the variable information with
434
        their default values. Users should implement this function in their
435
        model class, and it should return a dictionary with the variable names
436
        as keys and the variable information as values. The variable information
437
        should contain at least the following keys:
438

439
        - "history": int - The number of periods that the variable requires information from.
440
        - "sectors": list - The sectors that the variable is associated with.
441
        - "unit": str - The unit of the variable.
442
        - "notation": str - The notation of the variable.
443

444
        """
445
        return {}
1✔
446

447
    def initialize_tensors(self):
1✔
448
        """Initialize the output tensors, creating two different dictionaries.
449
        First, a dictionary for the state variables (i.e. those that require
450
        only t-1 information, but no history) and second a dictionary for the
451
        history variables (i.e. those that require information from further
452
        previous periods).
453
        """
454
        # State variables (only t-1 information)
455
        state_vars = self.new_state()
1✔
456

457
        # History variables (v["history"] rows)
458
        self.history = {}
1✔
459
        for k, v in self.info.items():
1✔
460
            if "history" in v and v["history"] > 0:
1✔
461
                self.history[k] = []
1✔
462

463
        # Initialize the timeseries
464
        self.timeseries_list = {k: [] for k in self.info}
1✔
465
        self.timeseries = self.gather_timeseries()
1✔
466
        return state_vars, self.history
1✔
467

468
    def new_state(self, **kwargs):
1✔
469
        """Initialize the state variables for the given period."""
470

471
        state = {}
1✔
472
        for k, v in self.info.items():
1✔
473
            if "matrix" in v and len(v["sectors"]) > 0:
1✔
NEW
474
                state[k] = torch.zeros(
×
475
                    len(v["sectors"]), len(v["matrix"]), **self.tensor_kwargs
476
                )
477
            elif "sectors" in v and len(v["sectors"]) > 0:
1✔
478
                state[k] = torch.zeros(len(v["sectors"]), **self.tensor_kwargs)
1✔
479
            else:
480
                state[k] = torch.zeros(1, **self.tensor_kwargs)
1✔
481

482
        return state
1✔
483

484
    def update_history(self, state: dict):
1✔
485
        """Update the history variables for the given period.
486

487
        Parameters
488
        ----------
489
        state: dict
490
            The state variables for the given period.
491
        history: dict
492
            The history variables for the given period.
493
        """
494

495
        for k, v in self.history.items():
1✔
496
            try:
1✔
497
                # If the list is full we need to delete an item:
498
                if len(v) >= self.info[k]["history"]:
1✔
499
                    del v[-1]
1✔
500
                # Insert into position 0 as newest element
501
                v.insert(0, state[k].squeeze())
1✔
502
            except Exception as e:
×
503
                logger.error(f"Update history failed for {k}. Value is {v}")
×
504
                raise e
×
505

506
        vhistory = {}
1✔
507
        for k, v in self.history.items():
1✔
508
            vhistory[k] = torch.stack(v, dim=0)
1✔
509

510
        return vhistory
1✔
511

512
    def record_state(
1✔
513
        self,
514
        t: int,
515
        state_vars: dict,
516
    ):
517
        """Record the state variables for the given period.
518

519
        Parameters
520
        ----------
521
        t: int
522
            The period to record the state variables for.
523
        state_vars: dict
524
            The state variables to record.
525
        """
526
        key_state = set(state_vars.keys())
1✔
527
        key_series = set(self.timeseries_list.keys())
1✔
528

529
        # Warn if there are keys that are in the state variables
530
        # but not in the timeseries
531
        if len(key_state - key_series) > 0:
1✔
532
            msg = "keys in state variables but not timeseries"
1✔
533
            logger.warning(f"{msg}: {key_state - key_series}")
1✔
534

535
        # Only keep the keys that are in both dictionaries
536
        for k in list(key_state.intersection(key_series)):
1✔
537
            try:
1✔
538
                self.timeseries_list[k].append(state_vars[k].clone())
1✔
539
                # self.timeseries[k][t, :] = state_vars[k].clone().detach()
UNCOV
540
            except Exception as e:
×
UNCOV
541
                logger.error(f"Error recording {k}:")
×
UNCOV
542
                logger.error(f"State: {state_vars[k].clone().detach()}")
×
NEW
543
                logger.error(f"Timeseries: {self.timeseries_list[k][t, :]}")
×
UNCOV
544
                raise e
×
545

546
        self.gather_timeseries()
1✔
547

548
    def _timeseries_tensor_to_list(self, tensordict):
1✔
549
        """Populate the self.timeseries_list given a tensor (e.g. from the
550
        initialization or similar. This ensures the gather_timeseries has the
551
        correct underlying info
552
        """
553
        new = {}
1✔
554
        for k, v in tensordict.items():
1✔
555
            new[k] = [v[i] for i in range(v.shape[0])]
1✔
556
        self.timeseries_list.update(new)
1✔
557

558
    def gather_timeseries(self):
1✔
559
        """Gather the existing timeseries "lists" into single PyTorch tensors"""
560
        cat = {}
1✔
561
        t = self.parameters["timesteps"]
1✔
562

563
        for k, v in self.timeseries_list.items():
1✔
564
            if not v:
1✔
565
                cat[k] = torch.tensor(t * [float("nan")])
1✔
566
            else:
567
                new = torch.stack(v)
1✔
568
                if new.shape[0] < t:
1✔
569
                    none_to_add = torch.ones(t - new.shape[0], *new.shape[1:])
1✔
570
                    new = torch.cat([new, float("nan") * none_to_add], dim=0)
1✔
571
                cat[k] = new
1✔
572

573
        self.timeseries = cat
1✔
574
        return cat
1✔
575

576
    def verify_sfc_info(self):
1✔
577
        """Verify that the sfc information in the info dictionary is complete.
578

579
        This function checks first whether there is an sfc entry in the info
580
        dictionary for each variable. If there is, it then checks whether the
581
        sfc information makes sense, i.e. if they are flows they should have
582
        and "inflow" and "outflow" tuple in the list, otherwise they should
583
        contain at least one tuple with the first element being either "index",
584
        "asset" or "liability".
585
        """
586

587
        for k, v in self.info.items():
1✔
588
            if "sfc" not in v:
1✔
589
                logger.warning(f"No SFC information for {k}")
1✔
590
                return False
1✔
591

592
            if not isinstance(v["sfc"], (tuple, list)):
1✔
593
                logger.warning(f"Sfc information for {k} is not a list or tuple")
1✔
594
                return False
1✔
595
            elif isinstance(v["sfc"], tuple):
1✔
596
                if self._verify_sfc_item(v["sfc"], k):
1✔
597
                    continue
1✔
598
                else:
599
                    return False
1✔
600
            else:  # isinstance(v["sfc"], list):
601
                for sfc in v["sfc"]:
1✔
602
                    if self._verify_sfc_item(sfc, k):
1✔
603
                        continue
1✔
604
                    else:
605
                        return False
×
606

607
        return True
1✔
608

609
    ############################################################################
610
    # Helper Functions
611
    ############################################################################
612

613
    @staticmethod
1✔
614
    def _apply_math_format(df: pd.DataFrame, mathfmt: str):
1✔
615
        """Apply a math format to a DataFrame."""
616
        # Optionally wrap the notation in math mode
617
        if mathfmt == "sphinx":
1✔
618
            nonemask = df.isna()
1✔
619
            df = df.map(lambda x: r":math:`" + str(x) + r"`")
1✔
620
            df[nonemask] = ""
1✔
621
        elif mathfmt in ["myst", "latex"]:
1✔
622
            nonemask = df.isna()
1✔
623
            df = df.map(lambda x: r"$" + str(x) + r"$")
1✔
624
            df[nonemask] = ""
1✔
625
        else:
626
            raise ValueError(f"Invalid math format: {mathfmt}")
1✔
627

628
        return df
1✔
629

630
    def _verify_sfc_item(self, sfc: tuple, key: str):
1✔
631
        """Verify that an sfc item is valid by checking the first element is
632
        an accepted stock/flow/index type and that there are two elements in
633
        the tuple.
634

635
        Parameters
636
        ----------
637
        sfc: tuple
638
            The sfc item to verify.
639
        key: str
640
            The key of the variable.
641
        """
642
        if not isinstance(sfc[0], str):
1✔
643
            logger.warning(f"sfc information for {key} is not a valid item")
1✔
644
            return False
1✔
645
        elif sfc[0].lower() not in [
1✔
646
            "inflow",
647
            "outflow",
648
            "index",
649
            "asset",
650
            "liability",
651
        ]:
652
            logger.warning(f"sfc information for {key} is not a valid item")
1✔
653
            return False
1✔
654
        if len(sfc) != 2:
1✔
655
            logger.warning(
1✔
656
                f"sfc information for {key} is not a valid tuple of length 2: {sfc}"
657
            )
658
            return False
1✔
659
        return True
1✔
660

661
    @staticmethod
1✔
662
    def _convert_sector_to_tuples(sector):
1✔
663
        """The point of this method is to ensure that for the SFC tuples, there
664
        is always a sector and balance sheet section, if there is no balance
665
        sheet section, it is assumed to be the current account.
666

667
        Parameters
668
        ----------
669
        sector: str | tuple | list
670
            The sector to convert.
671

672
        Returns
673
        -------
674
        tuple
675
            A tuple of the sector and balance sheet section.
676
        """
677
        # Convert the sector to a tuple from str or list
678
        if isinstance(sector, list):
1✔
679
            sector = tuple(sector)
1✔
680
        elif isinstance(sector, str):
1✔
681
            sector = (sector, "Current")
1✔
682

683
        # Check that the sector is a tuple
684
        if not isinstance(sector, tuple):
1✔
685
            raise ValueError(f"Sector {sector} is not a tuple")
1✔
686
        elif len(sector) != 2:
1✔
687
            raise ValueError(f"Sector {sector} is not a tuple of length 2")
1✔
688
        else:
689
            return sector
1✔
690

691

692
if __name__ == "__main__":
693
    pass
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