Skip to content

Commit 9f88a1d

Browse files
authored
Add capacity reservation breakdown for invoices (#4263)
The challenge request budget table has been updated to break down the calculation for the capacity reservation. Example: <img width="600" alt="Screenshot 2025-08-25 at 11 35 06" src="https://github.com/user-attachments/assets/7bc38c72-9394-41f9-92d5-b6cc81d36ce3" /> Related to DIAGNijmegen/rse-roadmap#421 Related to #3787
1 parent d2d13d7 commit 9f88a1d

16 files changed

Lines changed: 498 additions & 300 deletions

File tree

app/config/settings.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,17 +144,16 @@
144144
# About Flatpage
145145
FLATPAGE_ABOUT_URL = os.environ.get("FLATPAGE_ABOUT_URL", "/about/")
146146

147-
# All costs exclude Tax
148-
COMPONENTS_TAX_RATE_PERCENT = 0.21
149-
if COMPONENTS_TAX_RATE_PERCENT > 1:
147+
COMPONENTS_TAX_RATE = 0.21
148+
if COMPONENTS_TAX_RATE > 1:
150149
raise ImproperlyConfigured("Tax rate should be less than 1")
151150
COMPONENTS_USD_TO_EUR = float(
152151
os.environ.get("COMPONENTS_USD_TO_EUR", "0.92472705")
153152
)
154-
COMPONENTS_S3_USD_MILLICENTS_PER_YEAR_PER_TB = (
153+
COMPONENTS_S3_USD_MILLICENTS_PER_YEAR_PER_TB_EXCLUDING_TAX = (
155154
12_300_000 # Last calculated 23/08/2023
156155
)
157-
COMPONENTS_ECR_USD_MILLICENTS_PER_YEAR_PER_TB = (
156+
COMPONENTS_ECR_USD_MILLICENTS_PER_YEAR_PER_TB_EXCLUDING_TAX = (
158157
39_600_000 # Last calculated 23/08/2023
159158
)
160159

@@ -164,10 +163,8 @@
164163
CHALLENGE_MINIMAL_COMPUTE_AND_STORAGE_IN_EURO = int(
165164
os.environ.get("CHALLENGE_MINIMAL_COMPUTE_AND_STORAGE_IN_EURO", 1000)
166165
)
167-
CHALLENGE_ADDITIONAL_COMPUTE_AND_STORAGE_PACK_SIZE_IN_EURO = int(
168-
os.environ.get(
169-
"CHALLENGE_ADDITIONAL_COMPUTE_AND_STORAGE_PACK_SIZE_IN_EURO", 500
170-
)
166+
CHALLENGE_CAPACITY_RESERVATION_PACK_SIZE_IN_EURO = int(
167+
os.environ.get("CHALLENGE_CAPACITY_RESERVATION_PACK_SIZE_IN_EURO", 500)
171168
)
172169
CHALLENGE_NUM_SUPPORT_YEARS = int(
173170
os.environ.get("CHALLENGE_NUM_SUPPORT_YEARS", 5)

app/grandchallenge/algorithms/models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1180,7 +1180,6 @@ def init_credits_consumed(self):
11801180
maximum_cents_per_job = (
11811181
(self.time_limit / 3600)
11821182
* executor.usd_cents_per_hour
1183-
* (1 + settings.COMPONENTS_TAX_RATE_PERCENT)
11841183
* settings.COMPONENTS_USD_TO_EUR
11851184
)
11861185

app/grandchallenge/challenges/admin.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,7 @@ class ChallengeRequestAdmin(ModelAdmin):
122122

123123
@admin.display(description="Total cost")
124124
def total_cost(self, obj):
125-
if obj.budget:
126-
return obj.budget.get("Total")
127-
else:
128-
return None
125+
return obj.total_challenge_cost
129126

130127
@admin.action(description="Create challenge for this request")
131128
def create_challenge(self, request, queryset):

app/grandchallenge/challenges/models.py

Lines changed: 122 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,39 +1236,58 @@ def total_compute_time(self):
12361236
return self.phase_1_compute_time + self.phase_2_compute_time
12371237

12381238
@property
1239-
def phase_1_storage_size_bytes(self):
1239+
def phase_1_data_storage_size_bytes(self):
12401240
return (
12411241
self.phase_1_number_of_test_images
12421242
* (self.average_size_of_test_image_in_mb * settings.MEGABYTE)
12431243
* self.number_of_tasks
12441244
)
12451245

12461246
@property
1247-
def phase_2_storage_size_bytes(self):
1247+
def phase_2_data_storage_size_bytes(self):
12481248
return (
12491249
self.phase_2_number_of_test_images
12501250
* (self.average_size_of_test_image_in_mb * settings.MEGABYTE)
12511251
* self.number_of_tasks
12521252
)
12531253

12541254
@property
1255-
def docker_s3_storage_size_bytes(self):
1255+
def num_unique_docker_images_per_team(self):
1256+
# A docker for a later phase should also be submitted to an earlier one.
1257+
return max(
1258+
self.phase_1_number_of_submissions_per_team,
1259+
self.phase_2_number_of_submissions_per_team,
1260+
)
1261+
1262+
@property
1263+
def num_unique_docker_images(self):
1264+
return (
1265+
self.num_unique_docker_images_per_team
1266+
* self.expected_number_of_teams
1267+
* self.number_of_tasks
1268+
)
1269+
1270+
@property
1271+
def docker_storage_size_gb(self):
12561272
return (
1257-
# 1 unique container image per submission
1258-
(self.average_algorithm_container_size_in_gb * settings.GIGABYTE)
1259-
* self.total_num_submissions
1273+
self.average_algorithm_container_size_in_gb
1274+
* self.num_unique_docker_images
12601275
)
12611276

1277+
@property
1278+
def docker_storage_size_bytes(self):
1279+
return self.docker_storage_size_gb * settings.GIGABYTE
1280+
12621281
@property
12631282
def total_data_and_docker_storage_bytes(self):
12641283
return (
1265-
self.docker_s3_storage_size_bytes
1266-
+ self.phase_1_storage_size_bytes
1267-
+ self.phase_2_storage_size_bytes
1284+
self.docker_storage_size_bytes
1285+
+ self.phase_1_data_storage_size_bytes
1286+
+ self.phase_2_data_storage_size_bytes
12681287
)
12691288

12701289
@cached_property
1271-
def compute_euro_cents_per_hour(self):
1290+
def compute_costs_euro_cents_per_hour(self):
12721291
executors = [
12731292
import_string(settings.COMPONENTS_DEFAULT_BACKEND)(
12741293
job_id="",
@@ -1285,40 +1304,54 @@ def compute_euro_cents_per_hour(self):
12851304
)
12861305
return usd_cents_per_hour * settings.COMPONENTS_USD_TO_EUR
12871306

1307+
@property
1308+
def compute_costs_euros_per_hour(self):
1309+
return self.compute_costs_euro_cents_per_hour / 100
1310+
12881311
def get_compute_costs_euros(self, duration):
1289-
return self.round_to_10_euros(
1290-
duration.total_seconds()
1291-
* (1 + settings.COMPONENTS_TAX_RATE_PERCENT)
1292-
* self.compute_euro_cents_per_hour
1293-
/ 3600
1312+
return (
1313+
math.ceil(
1314+
self.compute_costs_euro_cents_per_hour
1315+
* duration.total_seconds()
1316+
/ 3600
1317+
)
1318+
/ 100
12941319
)
12951320

1296-
@classmethod
1297-
def get_data_storage_costs_euros(cls, size_bytes):
1298-
return cls.round_to_10_euros(
1299-
size_bytes
1300-
* settings.CHALLENGE_NUM_SUPPORT_YEARS
1301-
* (1 + settings.COMPONENTS_TAX_RATE_PERCENT)
1321+
@property
1322+
def storage_costs_euros_per_gb(self):
1323+
return (
1324+
settings.CHALLENGE_NUM_SUPPORT_YEARS
1325+
* settings.COMPONENTS_S3_USD_MILLICENTS_PER_YEAR_PER_TB_EXCLUDING_TAX
1326+
* (1 + settings.COMPONENTS_TAX_RATE)
13021327
* settings.COMPONENTS_USD_TO_EUR
1303-
* settings.COMPONENTS_S3_USD_MILLICENTS_PER_YEAR_PER_TB
13041328
/ 1000
1329+
/ 100
13051330
/ settings.TERABYTE
1331+
* settings.GIGABYTE
13061332
)
13071333

1308-
@classmethod
1309-
def round_to_10_euros(cls, cents):
1310-
return 10 * math.ceil(cents / 100 / 10)
1334+
def get_storage_costs_euros(self, size_bytes):
1335+
return (
1336+
math.ceil(
1337+
self.storage_costs_euros_per_gb
1338+
* 100
1339+
* size_bytes
1340+
/ settings.GIGABYTE
1341+
)
1342+
/ 100
1343+
)
13111344

13121345
@property
1313-
def phase_1_data_storage_euros(self):
1314-
return self.get_data_storage_costs_euros(
1315-
self.phase_1_storage_size_bytes
1346+
def phase_1_data_storage_costs_euros(self):
1347+
return self.get_storage_costs_euros(
1348+
self.phase_1_data_storage_size_bytes
13161349
)
13171350

13181351
@property
1319-
def phase_2_data_storage_euros(self):
1320-
return self.get_data_storage_costs_euros(
1321-
self.phase_2_storage_size_bytes
1352+
def phase_2_data_storage_costs_euros(self):
1353+
return self.get_storage_costs_euros(
1354+
self.phase_2_data_storage_size_bytes
13221355
)
13231356

13241357
@property
@@ -1332,20 +1365,20 @@ def phase_2_compute_costs_euros(self):
13321365
@property
13331366
def phase_1_total_euros(self):
13341367
return (
1335-
self.phase_1_data_storage_euros + self.phase_1_compute_costs_euros
1368+
self.phase_1_data_storage_costs_euros
1369+
+ self.phase_1_compute_costs_euros
13361370
)
13371371

13381372
@property
13391373
def phase_2_total_euros(self):
13401374
return (
1341-
self.phase_2_data_storage_euros + self.phase_2_compute_costs_euros
1375+
self.phase_2_data_storage_costs_euros
1376+
+ self.phase_2_compute_costs_euros
13421377
)
13431378

13441379
@property
13451380
def docker_storage_costs_euros(self):
1346-
return self.get_data_storage_costs_euros(
1347-
self.docker_s3_storage_size_bytes
1348-
)
1381+
return self.get_storage_costs_euros(self.docker_storage_size_bytes)
13491382

13501383
@cached_property
13511384
def base_cost_euros(self):
@@ -1359,42 +1392,11 @@ def base_cost_euros(self):
13591392
else:
13601393
return settings.CHALLENGE_BASE_COST_IN_EURO
13611394

1362-
@property
1363-
def total_euros_storage_and_compute(self):
1364-
return (
1365-
self.phase_1_total_euros
1366-
+ self.phase_2_total_euros
1367-
+ self.docker_storage_costs_euros
1368-
)
1369-
1370-
@cached_property
1371-
def budget(self):
1372-
try:
1373-
return {
1374-
"Data storage cost for phase 1": self.phase_1_data_storage_euros,
1375-
"Compute costs for phase 1": self.phase_1_compute_costs_euros,
1376-
"Total phase 1": self.phase_1_total_euros,
1377-
"Data storage cost for phase 2": self.phase_2_data_storage_euros,
1378-
"Compute costs for phase 2": self.phase_2_compute_costs_euros,
1379-
"Total phase 2": self.phase_2_total_euros,
1380-
"Docker storage cost": self.docker_storage_costs_euros,
1381-
"Total across phases": self.total_euros_storage_and_compute,
1382-
}
1383-
except TypeError:
1384-
return None
1385-
1386-
@property
1387-
def storage_and_compute_cost_surplus(self):
1388-
return (
1389-
self.total_euros_storage_and_compute
1390-
- settings.CHALLENGE_MINIMAL_COMPUTE_AND_STORAGE_IN_EURO
1391-
)
1392-
13931395
@property
13941396
def total_storage_costs_euros(self):
13951397
return (
1396-
self.phase_1_data_storage_euros
1397-
+ self.phase_2_data_storage_euros
1398+
self.phase_1_data_storage_costs_euros
1399+
+ self.phase_2_data_storage_costs_euros
13981400
+ self.docker_storage_costs_euros
13991401
)
14001402

@@ -1404,69 +1406,73 @@ def total_compute_costs_euros(self):
14041406
self.phase_1_compute_costs_euros + self.phase_2_compute_costs_euros
14051407
)
14061408

1407-
def calculate_invoiced_amount(self, *, cost, ratio):
1408-
if self.storage_and_compute_cost_surplus <= 0:
1409-
# Below minimum price, add proportional shortfall
1410-
return round(
1411-
cost + ratio * abs(self.storage_and_compute_cost_surplus)
1412-
)
1413-
else:
1414-
# Above minimum price, allocate surplus proportionally
1415-
return round(
1416-
ratio
1417-
* (
1418-
self.additional_compute_and_storage_costs
1419-
+ settings.CHALLENGE_MINIMAL_COMPUTE_AND_STORAGE_IN_EURO
1420-
)
1421-
)
1422-
14231409
@property
1424-
def compute_cost_ratio(self):
1425-
return (
1426-
self.total_compute_costs_euros
1427-
/ self.total_euros_storage_and_compute
1428-
)
1410+
def total_compute_and_storage_costs_euros(self):
1411+
return self.total_storage_costs_euros + self.total_compute_costs_euros
14291412

14301413
@property
1431-
def total_storage_to_be_invoiced(self):
1432-
return self.calculate_invoiced_amount(
1433-
cost=self.total_storage_costs_euros,
1434-
ratio=1 - self.compute_cost_ratio,
1414+
def capacity_reservation_units(self):
1415+
return math.ceil(
1416+
max(
1417+
settings.CHALLENGE_MINIMAL_COMPUTE_AND_STORAGE_IN_EURO,
1418+
self.total_compute_and_storage_costs_euros,
1419+
)
1420+
/ settings.CHALLENGE_CAPACITY_RESERVATION_PACK_SIZE_IN_EURO
14351421
)
14361422

14371423
@property
1438-
def total_compute_to_be_invoiced(self):
1439-
return self.calculate_invoiced_amount(
1440-
cost=self.total_compute_costs_euros,
1441-
ratio=self.compute_cost_ratio,
1424+
def capacity_reservation_euros(self):
1425+
return (
1426+
self.capacity_reservation_units
1427+
* settings.CHALLENGE_CAPACITY_RESERVATION_PACK_SIZE_IN_EURO
14421428
)
14431429

14441430
@property
1445-
def minimal_challenge_cost(self):
1431+
def capacity_reservation_compute_euros(self):
14461432
return (
1447-
self.base_cost_euros
1448-
+ settings.CHALLENGE_MINIMAL_COMPUTE_AND_STORAGE_IN_EURO
1433+
self.total_compute_costs_euros
1434+
/ self.total_compute_and_storage_costs_euros
1435+
* self.capacity_reservation_euros
14491436
)
14501437

14511438
@property
1452-
def additional_compute_and_storage_costs(self):
1453-
if self.storage_and_compute_cost_surplus <= 0:
1454-
return 0
1455-
else:
1456-
return (
1457-
math.ceil(
1458-
self.storage_and_compute_cost_surplus
1459-
/ settings.CHALLENGE_ADDITIONAL_COMPUTE_AND_STORAGE_PACK_SIZE_IN_EURO
1460-
)
1461-
* settings.CHALLENGE_ADDITIONAL_COMPUTE_AND_STORAGE_PACK_SIZE_IN_EURO
1462-
)
1439+
def capacity_reservation_storage_euros(self):
1440+
return (
1441+
self.total_storage_costs_euros
1442+
/ self.total_compute_and_storage_costs_euros
1443+
* self.capacity_reservation_euros
1444+
)
14631445

14641446
@property
14651447
def total_challenge_cost(self):
1466-
return (
1467-
self.minimal_challenge_cost
1468-
+ self.additional_compute_and_storage_costs
1469-
)
1448+
return self.base_cost_euros + self.capacity_reservation_euros
1449+
1450+
@cached_property
1451+
def costs_for_phases(self):
1452+
return [
1453+
{
1454+
"name": "Phase 1",
1455+
"number_of_submissions_per_team": self.phase_1_number_of_submissions_per_team,
1456+
"number_of_test_images": self.phase_1_number_of_test_images,
1457+
"compute_time": self.phase_1_compute_time,
1458+
"compute_costs_euros": self.phase_1_compute_costs_euros,
1459+
"data_storage_size_gb": self.phase_1_data_storage_size_bytes
1460+
/ settings.GIGABYTE,
1461+
"data_storage_costs_euros": self.phase_1_data_storage_costs_euros,
1462+
"total_euros": self.phase_1_total_euros,
1463+
},
1464+
{
1465+
"name": "Phase 2",
1466+
"number_of_submissions_per_team": self.phase_2_number_of_submissions_per_team,
1467+
"number_of_test_images": self.phase_2_number_of_test_images,
1468+
"compute_time": self.phase_2_compute_time,
1469+
"compute_costs_euros": self.phase_2_compute_costs_euros,
1470+
"data_storage_size_gb": self.phase_2_data_storage_size_bytes
1471+
/ settings.GIGABYTE,
1472+
"data_storage_costs_euros": self.phase_2_data_storage_costs_euros,
1473+
"total_euros": self.phase_2_total_euros,
1474+
},
1475+
]
14701476

14711477

14721478
class ChallengeRequestUserObjectPermission(UserObjectPermissionBase):

0 commit comments

Comments
 (0)