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

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# 

18 

19import numpy 

20 

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 

26 

27 

28def calculate_normalized_score(score: float, scores: list[float]) -> float: 

29 """Normalize an individual score given a list of individual scores to normalize against 

30 

31 From Allie Costa: 

32 

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. 

39 

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 

49 

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) 

58 

59 

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 

65 

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 """ 

72 

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 ) 

120 

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 ) 

128 

129 # return all updated isrs: 

130 return updated_isrs