Coverage for middle_layer/review/application_layer/services/finalize_isrs.py: 90.57%
53 statements
« prev ^ index » next coverage.py v7.10.5, created at 2026-04-13 06:13 +0000
« prev ^ index » next coverage.py v7.10.5, created at 2026-04-13 06:13 +0000
1# Copyright 2024 Associated Universities, Inc.
2#
3# This file is part of Telescope Time Allocation Tools (TTAT).
4#
5# TTAT is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# any later version.
9#
10# TTAT is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with TTAT. If not, see <https://www.gnu.org/licenses/>.
17#
19import numpy
21from common.application_layer.orm_repositories.orm_repository import ORMRepository
22from review.domain_layer.entities.individual_science_review import IndividualScienceReview
23from review.domain_layer.entities.science_reviewer import ScienceReviewer
24from review.domain_layer.services.validate_isr_score_service import validate_isr_score
25from review.domain_layer.services.validate_isr_state_change_service import validate_isr_state_change
28def calculate_normalized_score(score: float, scores: list[float]) -> float:
29 """Normalize an individual score given a list of individual scores to normalize against
31 From Allie Costa:
33 No two reviewers treat scores the same way. To impose some uniformity on them, normalized scores are derived from
34 the 'raw' scores. Although these are often not Gaussian, there is some attempt to make the normalized scores
35 Gaussian. For each reviewer, the mean and standard deviation of the (non-zero) raw scores is derived. The
36 normalized scores for each proposal are then normalized_score = a * s + b, where a = 2/stddev(s) and b = 5-a * mean.
37 Here s2 and theta(s) are the mean and standard deviation of the raw score(s), respectively. If the parent
38 distribution is Gaussian, this will transform it so that it has a standard deviation of 2 with a mean of 5.
40 :param score: the individual score to be normalized
41 :param scores: the list of individual scores that this score is normalized against
42 :return: the normalized score, rounded to the nearest tenth.
43 :raises ZeroDivisionError: When the result would be NaN, which typically occurs when
44 the elements of scores are all equal
45 """
46 # If there is only one score in the list, we cannot normalize. Just return the individual score.
47 if len(scores) == 1:
48 return score
50 mean = numpy.mean(scores)
51 stdev = numpy.std(scores)
52 a = 2 / stdev
53 b = 5 - a * mean
54 normalized_score = a * score + b
55 if numpy.isnan(normalized_score):
56 raise ZeroDivisionError
57 return numpy.round(normalized_score, 1)
60def individual_science_reviews_finalize(
61 reviewer: ScienceReviewer, is_tta_member: bool, repo: ORMRepository
62) -> list[IndividualScienceReview]:
63 """Finalize all IndividualScienceReviews for a given ScienceReviewer, either raising an error on invalid ones
64 or closing them if the requester is a TTA Member
66 :param reviewer: the ScienceReviewer whose ISRs are being finalized
67 :param is_tta_member: whether the user requesting finalization is a TTA Member
68 :param repo: Repository for database querying
69 :return: a list of the updated IndividualScienceReviews
70 :raises ValueError: When an ISR is invalid and is_tta_member is False, or score normalization fails
71 """
73 # get the reviewer's ISRS:
74 isrs = repo.individual_science_review_repo.list_by_reviewer_id(reviewer.science_reviewer_id)
75 # filter out the Closed and None:
76 validated_isrs = []
77 updated_isrs = []
78 scores = []
79 for isr in isrs:
80 if isr.review_type != "None" and isr.review_state != "Closed":
81 if len(isr.comments_for_the_srp) == 0:
82 if is_tta_member:
83 isr.review_state = "Closed"
84 isr.review_type = "None"
85 updated_isrs.append(isr)
86 else:
87 raise ValueError(f"review for proposal {isr.proposal_id} has no comment.")
88 elif not validate_isr_score(isr.individual_score):
89 if is_tta_member:
90 isr.review_state = "Closed"
91 isr.review_type = "None"
92 updated_isrs.append(isr)
93 else:
94 raise ValueError(
95 f"review for proposal {isr.proposal_id} must have a score between 0 and 10 exclusive in "
96 f"0.1 increments (i.e. 0.1-9.9)."
97 )
98 else: # there is a comment and score is valid:
99 validated_isrs.append(isr)
100 updated_isrs.append(isr)
101 scores.append(isr.individual_score)
102 # now we have a list of the finalize-able isrs, we can calculate normalized scores:
103 for isr in validated_isrs:
104 if isr.review_state != "Closed":
105 if isr.review_state != "Finalized":
106 # if the isr is not already finalized, set its state to finalized.
107 validation_message = validate_isr_state_change(
108 isr.review_state, "Finalized", is_tta_member, is_finalize_call=True
109 )
110 if validation_message:
111 raise ValueError(validation_message)
112 isr.review_state = "Finalized"
113 try:
114 # check that individual scores are not all the same:
115 if len(scores) > 1 and max(scores) == min(scores):
116 raise ValueError(
117 f"Expected normalizable individual scores not to all be the same, found instead "
118 f"{scores} for ScienceReviewer with User ID {reviewer.user_id}"
119 )
121 # calculate normalized scores for both new and existing finalized isrs.
122 isr.normalized_score = calculate_normalized_score(isr.individual_score, scores)
123 except ZeroDivisionError:
124 raise ValueError(
125 f"Encountered division by zero when normalizing scores for ScienceReviewer with "
126 f"User ID {reviewer.user_id} with individual scores: {scores}"
127 )
129 # return all updated isrs:
130 return updated_isrs