Skip to content

Commit 4df1773

Browse files
committed
Add royalty mechanism
This involved several changes: - New field `moc_member` in institution list - New pipeline setting `royalty_rate`, default to fetching from nerc_rates - Coldfront processor will now obtain info on whether a project is externally funded from Coldfront API - New processor and invoice to calculate and export the royalties
1 parent 5c0f578 commit 4df1773

15 files changed

Lines changed: 282 additions & 12 deletions

process_report/invoices/invoice.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class InvoiceColumn:
7676
CREDIT_CODE_FIELD = "Credit Code"
7777
SUBSIDY_FIELD = "Subsidy"
7878
BALANCE_FIELD = "Balance"
79+
ROYALTY_FIELD = "Royalty"
7980
###
8081

8182
### Internally used field names
@@ -86,6 +87,7 @@ class InvoiceColumn:
8687
GROUP_MANAGED_FIELD = "MGHPCC Managed"
8788
CLUSTER_NAME_FIELD = "Cluster Name"
8889
IS_COURSE_FIELD = "Is Course"
90+
IS_EXTERNALLY_FUNDED_FIELD = "Is Externally Funded"
8991
###
9092

9193
### Initialized Column objects
@@ -142,6 +144,10 @@ class InvoiceColumn:
142144
IS_COURSE_COLUMN = InvoiceColumn(
143145
name=IS_COURSE_FIELD, dtype=BOOL_FIELD_TYPE, default_value=False
144146
)
147+
IS_EXTERNALLY_FUNDED_COLUMN = InvoiceColumn(
148+
name=IS_EXTERNALLY_FUNDED_FIELD, dtype=BOOL_FIELD_TYPE, default_value=False
149+
) # TODO: We are fine with this default?
150+
ROYALTY_COLUMN = InvoiceColumn(name=ROYALTY_FIELD, dtype=BALANCE_FIELD_TYPE)
145151
###
146152

147153

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from dataclasses import dataclass
2+
3+
import process_report.invoices.invoice as invoice
4+
5+
6+
@dataclass
7+
class RoyaltyInvoice(invoice.Invoice):
8+
name: str = "Royalties"
9+
export_columns_list = [ # TODO: Confirm list of information we want to include in royalty report
10+
invoice.INVOICE_DATE_FIELD,
11+
invoice.PROJECT_FIELD,
12+
invoice.PI_FIELD,
13+
invoice.CLUSTER_NAME_FIELD,
14+
invoice.INSTITUTION_FIELD,
15+
invoice.SU_TYPE_FIELD,
16+
invoice.BALANCE_FIELD,
17+
invoice.ROYALTY_FIELD,
18+
]
19+
20+
def _prepare_export(self):
21+
self.export_data = self.data[~self.data[invoice.ROYALTY_FIELD].isna()]

process_report/loader.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,5 +215,10 @@ def get_nonbillable_timed_projects(self) -> list[tuple[str, str]]:
215215
].itertuples(index=False, name=None)
216216
)
217217

218+
@functools.lru_cache
219+
def get_royalty_exempt_institutions_list(self) -> tuple[str]:
220+
with open(invoice_settings.royalty_exempt_institutions_filepath) as f:
221+
return tuple(f.read().splitlines())
222+
218223

219224
loader = Loader()

process_report/process_report.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
MOCA_prepaid_invoice,
1919
prepay_credits_snapshot,
2020
ocp_test_invoice,
21+
royalty_invoice,
2122
)
2223
from process_report.processors import (
2324
coldfront_fetch_processor,
@@ -31,6 +32,7 @@
3132
bu_subsidy_processor,
3233
prepayment_processor,
3334
validate_cluster_name_processor,
35+
royalty_processor,
3436
)
3537

3638
PROCESSING_ORDER = [
@@ -45,6 +47,7 @@
4547
new_pi_credit_processor.NewPICreditProcessor,
4648
bu_subsidy_processor.BUSubsidyProcessor,
4749
prepayment_processor.PrepaymentProcessor,
50+
royalty_processor.RoyaltyProcessor,
4851
]
4952

5053

@@ -97,6 +100,7 @@ def main():
97100
MOCA_prepaid_invoice.MOCAPrepaidInvoice,
98101
prepay_credits_snapshot.PrepayCreditsSnapshot,
99102
ocp_test_invoice.OcpTestInvoice,
103+
royalty_invoice.RoyaltyInvoice,
100104
],
101105
invoice_settings.upload_to_s3,
102106
)

process_report/processors/coldfront_fetch_processor.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
CF_ATTR_ALLOCATED_PROJECT_ID = "Allocated Project ID"
2626
CF_ATTR_INSTITUTION_SPECIFIC_CODE = "Institution-Specific Code"
2727
CF_ATTR_IS_COURSE = "Is Course?"
28+
CF_ATTR_IS_EXTERNALLY_FUNDED = "Is Externally Funded"
2829

2930

3031
@dataclass
@@ -34,7 +35,10 @@ class ColdfrontFetchProcessor(processor.Processor):
3435
)
3536
coldfront_data_filepath: str = invoice_settings.coldfront_api_filepath
3637

37-
initializes_columns = (invoice.IS_COURSE_COLUMN,)
38+
initializes_columns = (
39+
invoice.IS_COURSE_COLUMN,
40+
invoice.IS_EXTERNALLY_FUNDED_COLUMN,
41+
)
3842
operates_on_columns = (
3943
*initializes_columns,
4044
invoice.PROJECT_COLUMN,
@@ -125,12 +129,19 @@ def _get_allocation_data(self, coldfront_api_data):
125129
project_dict["attributes"].get(CF_ATTR_IS_COURSE, "No").lower()
126130
== "yes"
127131
)
132+
is_externally_funded = (
133+
project_dict["project"]["attributes"]
134+
.get(CF_ATTR_IS_EXTERNALLY_FUNDED, "No")
135+
.lower()
136+
== "yes"
137+
)
128138
allocation_data[(project_id, cluster_name)] = {
129139
invoice.PROJECT_FIELD: project_name,
130140
invoice.PI_FIELD: pi_name,
131141
invoice.INSTITUTION_ID_FIELD: institute_code,
132142
invoice.CLUSTER_NAME_FIELD: cluster_name,
133143
invoice.IS_COURSE_FIELD: is_course,
144+
invoice.IS_EXTERNALLY_FUNDED_FIELD: is_externally_funded,
134145
}
135146
except KeyError:
136147
continue
@@ -164,6 +175,9 @@ def _apply_allocation_data(self, allocation_data):
164175
invoice.INSTITUTION_ID_FIELD
165176
]
166177
self.data.loc[mask, invoice.IS_COURSE_FIELD] = data[invoice.IS_COURSE_FIELD]
178+
self.data.loc[mask, invoice.IS_EXTERNALLY_FUNDED_FIELD] = data[
179+
invoice.IS_EXTERNALLY_FUNDED_FIELD
180+
]
167181

168182
def _process(self):
169183
api_data = self._get_coldfront_api_data()

process_report/processors/prepayment_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
@dataclass
1919
class PrepaymentProcessor(discount_processor.DiscountProcessor):
20-
IS_DISCOUNT_BY_NERC = True
20+
IS_DISCOUNT_BY_NERC = False
2121
PREPAY_DEBITS_S3_FILEPATH = "Prepay/prepay_debits.csv"
2222

2323
initializes_columns = (
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from decimal import Decimal
2+
import logging
3+
from dataclasses import dataclass, field
4+
5+
from process_report.loader import loader
6+
from process_report.settings import invoice_settings
7+
from process_report.invoices import invoice
8+
from process_report.processors import processor
9+
10+
11+
logger = logging.getLogger(__name__)
12+
logging.basicConfig(level=logging.INFO)
13+
14+
15+
@dataclass
16+
class RoyaltyProcessor(processor.Processor):
17+
"""
18+
Given a percentage royalty rate and list of exemept institutions, creates a new `Royalty` column equal to `Balance` * royalty_rate
19+
"""
20+
21+
royalty_rate: Decimal = invoice_settings.royalty_rate
22+
royalty_exempt_institution_list: tuple[str] = field(
23+
default_factory=loader.get_royalty_exempt_institutions_list
24+
)
25+
26+
initializes_columns = (invoice.ROYALTY_COLUMN,)
27+
operates_on_columns = (
28+
*initializes_columns,
29+
invoice.INSTITUTION_COLUMN,
30+
invoice.IS_EXTERNALLY_FUNDED_COLUMN,
31+
invoice.BALANCE_COLUMN,
32+
)
33+
34+
def _process(self):
35+
non_moc_member_mask = ~self.data[invoice.INSTITUTION_FIELD].isin(
36+
self.royalty_exempt_institution_list
37+
)
38+
externally_funded_mask = self.data[invoice.INSTITUTION_FIELD].isin(
39+
self.royalty_exempt_institution_list
40+
) & (self.data[invoice.IS_EXTERNALLY_FUNDED_FIELD] == True) # noqa: E712
41+
42+
self.data[invoice.ROYALTY_FIELD] = (
43+
self.data[invoice.BALANCE_FIELD] * self.royalty_rate
44+
).where(non_moc_member_mask | externally_funded_mask)

process_report/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ class Settings(BaseSettings):
3030
prepay_credits_filepath: str = "prepaid_credits.csv"
3131
prepay_contacts_filepath: str = "prepaid_contacts.csv"
3232

33+
# Royalty configuration
34+
royalty_rate: Decimal = Decimal("0.00")
35+
royalty_exempt_institutions_filepath: str = "royalty_exempt_institutions.txt"
36+
3337
# nerc_rates info
3438
new_pi_credit_amount: Decimal | None = None
3539
limit_new_pi_credit_to_partners: bool | None = None

process_report/tests/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"MGHPCC Managed": BOOL_FIELD_TYPE,
4444
"Cluster Name": STRING_FIELD_TYPE,
4545
"Is Course": BOOL_FIELD_TYPE,
46+
"Is Externally Funded": BOOL_FIELD_TYPE,
47+
"Royalty": BALANCE_FIELD_TYPE,
4648
}
4749

4850

process_report/tests/e2e/test_data/test_coldfront_api_data.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
{
33
"id": 1,
44
"project": {
5-
"pi": "pi1@bu.edu"
5+
"pi": "pi1@bu.edu",
6+
"attributes": {
7+
"Is Externally Funded": "Yes"
8+
}
69
},
710
"resource": {
811
"name": "shift"
@@ -15,7 +18,8 @@
1518
{
1619
"id": 1,
1720
"project": {
18-
"pi": "pi1@bu.edu"
21+
"pi": "pi1@bu.edu",
22+
"attributes": {}
1923
},
2024
"resource": {
2125
"name": "shift"
@@ -28,7 +32,8 @@
2832
{
2933
"id": 1,
3034
"project": {
31-
"pi": "pi2@harvard.edu"
35+
"pi": "pi2@harvard.edu",
36+
"attributes": {}
3237
},
3338
"resource": {
3439
"name": "shift"

0 commit comments

Comments
 (0)