Coverage for middle_layer/solicit/domain_layer/services/configure_solicitation_service.py: 94.02%

117 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2026-03-09 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# 

18import itertools 

19 

20from common import parse_iso_8601_strings 

21from common.application_layer.orm_repositories.orm_repository import ORMRepository 

22from common.application_layer.rest_api import make_expected_params_message 

23from solicit.domain_layer.entities import solicitation_facility_capability 

24from solicit.domain_layer.entities.array_configuration import ArrayConfiguration, ArrayConfigurationConfiguration 

25from solicit.domain_layer.entities.backend import Backend, BackendConfiguration 

26from solicit.domain_layer.entities.capability import Capability, Facility 

27from solicit.domain_layer.entities.default_instruction import DefaultInstruction, Instruction 

28from solicit.domain_layer.entities.frontend import Frontend, FrontendConfiguration 

29from solicit.domain_layer.entities.parameter_configuration import InputUnitGroups, ParameterConfiguration 

30from solicit.domain_layer.entities.solicitation import ( 

31 BAND_FREQUENCY_MAP_ALL, 

32 Band, 

33 Solicitation, 

34 SolicitationProposalClass, 

35 SolicitationProposalProcess, 

36) 

37from solicit.domain_layer.entities.solicitation_config import ( 

38 SolicitationCapabilityConfig, 

39 SolicitationCapabilityParameterSpecificationConfig, 

40 SolicitationConfig, 

41) 

42from solicit.domain_layer.entities.solicitation_facility_capability import SolicitationFacilityCapability 

43from solicit.domain_layer.services.default_parameters_service import configure_parameters_for_sfc 

44 

45 

46def configure_solicitation(config: dict[str, dict], repo: ORMRepository) -> Solicitation: 

47 """Configure a Solicitation based on a JSON object. 

48 This service aids in the "Configure Solicitation" use case 

49 satisfied by the 'add_solicitation_from_config_file' route. 

50 **NB:** The JSON object described below differs from that accepted by 

51 api.views.solicitation.construct_solicitation_from_json 

52 The config file should follow the formatting of 

53 `smoke_testing_helpers/both_capabilities_config.json` 

54 If 'observing_types' is included in the JSON entry of a Facility, 

55 the service will query the appropriate Capability and create a 

56 SolicitationCapability for the observing type. If only 'name' is defined 

57 for the Facility, a SolicitationCapability will not be added. 

58 

59 :param config: configuration dictionary used to configure a Solicitation and SolicitationCapabilities 

60 :param repo: ORMRepository from which to request full (ProposalProcess, Facility) objects from the given names 

61 :raises: KeyError if dictionary has improper keys or formatting 

62 :return: The configured Solicitation 

63 """ 

64 solicitation_config = config["solicitation"] 

65 expected_keys = [ 

66 "name", 

67 "proposalCodePrefix", 

68 "callPeriod", 

69 "executionPeriod", 

70 "gracePeriod", 

71 "testType", 

72 "scienceCategories", 

73 "proposalProcess", 

74 "facilities", 

75 "dispositionLetterTemplate", 

76 ] 

77 expected_keys_call_period = ["start", "end"] 

78 expected_keys_execution_period = ["start", "end"] 

79 if not all([expected in solicitation_config.keys() for expected in expected_keys]): 

80 raise KeyError(make_expected_params_message(expected_keys, solicitation_config.keys())) 

81 if not all([expected in solicitation_config["callPeriod"].keys() for expected in expected_keys_call_period]): 

82 raise KeyError( 

83 f"Expected JSON object to have keys {expected_keys_call_period} at 'callPeriod'. " 

84 f"Received {[key for key in solicitation_config['callPeriod'].keys()]}." 

85 ) 

86 if not all( 

87 [expected in solicitation_config["executionPeriod"].keys() for expected in expected_keys_execution_period] 

88 ): 

89 raise KeyError( 

90 f"Expected JSON object to have keys {expected_keys_execution_period} at 'executionPeriod'. " 

91 f"Received {[key for key in solicitation_config['executionPeriod'].keys()]}." 

92 ) 

93 if repo.solicitation_repo.by_proposal_code_prefix(solicitation_config["proposalCodePrefix"]): 

94 raise ValueError( 

95 f"Solicitation with Proposal ID prefix {solicitation_config['proposalCodePrefix']} already exists." 

96 ) 

97 call_period_start = parse_iso_8601_strings(solicitation_config["callPeriod"]["start"]) 

98 call_period_end = parse_iso_8601_strings(solicitation_config["callPeriod"]["end"]) 

99 execution_period_start = parse_iso_8601_strings(solicitation_config["executionPeriod"]["start"]) 

100 execution_period_end = parse_iso_8601_strings(solicitation_config["executionPeriod"]["end"]) 

101 grace_period = int(solicitation_config["gracePeriod"]) 

102 test_type = ( 

103 solicitation_config["testType"] 

104 if solicitation_config["testType"] == "Test" or solicitation_config["testType"] == "Demo" 

105 else "None" 

106 ) 

107 pp_config = solicitation_config["proposalProcess"] 

108 legacy = False 

109 if isinstance(pp_config, str): # old-style config file. TODO: get rid of these post refactor 

110 legacy = True 

111 proposal_process = repo.proposal_process_repo.by_name(pp_config) 

112 else: 

113 proposal_process = repo.proposal_process_repo.by_name(pp_config["proposalProcessName"]) 

114 

115 disposition_letter_template = solicitation_config["dispositionLetterTemplate"] 

116 

117 # using arbitrary notification group 

118 solicitation = Solicitation( 

119 solicitation_config["name"], 

120 solicitation_config["proposalCodePrefix"], 

121 call_period_start, 

122 call_period_end, 

123 execution_period_start, 

124 execution_period_end, 

125 grace_period, 

126 proposal_process, 

127 repo.notification_group_repo.by_id(1), 

128 test_type, 

129 disposition_letter_template=disposition_letter_template, 

130 ) 

131 # Try to read Facilities, Science Categories, and SolicitationCapabilities from solicitation_config 

132 facilities = [] 

133 science_categories = [] 

134 default_instructions = {di.shortcode: di for di in repo.session.query(DefaultInstruction).all()} 

135 if not legacy: 

136 for instruction in pp_config["defaultInstructions"]: 

137 # see if this default instruction already exists 

138 if instruction["shortcode"] in default_instructions: 

139 di = default_instructions[instruction["shortcode"]] 

140 else: 

141 di = DefaultInstruction() 

142 di.shortcode = instruction["shortcode"] 

143 di.dropdown_name = instruction["dropdownName"] 

144 di.instruction_text = instruction["instructionText"] 

145 default_instructions[di.shortcode] = di 

146 proposal_process.default_instructions.append(di) 

147 

148 spp = ( 

149 SolicitationProposalProcess(solicitation=solicitation, proposal_process=proposal_process) 

150 if not legacy 

151 else None 

152 ) 

153 repo.solicitation_repo.add(solicitation) 

154 for fac_config in solicitation_config["facilities"]: 

155 facility_name = fac_config["name"] 

156 facility = repo.facility_repo.by_name(facility_name) 

157 facilities.append(facility) 

158 

159 # fetch the constant sets from the DB 

160 fes = { 

161 fe.frontend_name: fe 

162 for fe in repo.session.query(Frontend) 

163 .join(Frontend.facility) 

164 .where(Facility.facility_name == facility_name) 

165 .all() 

166 } 

167 bes = { 

168 be.backend_name: be 

169 for be in repo.session.query(Backend) 

170 .join(Backend.facility) 

171 .where(Facility.facility_name == facility_name) 

172 .all() 

173 } 

174 acs = { 

175 ac.array_configuration_name: ac 

176 for ac in repo.session.query(ArrayConfiguration) 

177 .join(ArrayConfiguration.facility) 

178 .where(Facility.facility_name == facility_name) 

179 .all() 

180 } 

181 

182 for cap_config in fac_config["observingTypes"]: 

183 cap_name = cap_config["name"] 

184 sfc = SolicitationFacilityCapability( 

185 solicitation=solicitation, 

186 facility=facility, 

187 allow_triggered=cap_config["allowTriggered"], 

188 allow_monitored=cap_config["allowMonitored"], 

189 allow_fixed_date=cap_config["allowFixedDate"], 

190 regular_max_seconds=cap_config["regularMaxSeconds"], 

191 capability=repo.capability_repo.by_name(cap_name), 

192 execution_period_start=fac_config["executionPeriod"]["start"], 

193 execution_period_end=fac_config["executionPeriod"]["end"], 

194 ) 

195 configure_parameters_for_sfc(sfc) 

196 

197 # Create all the ParameterConfigurations for this SFC 

198 json_collections = { 

199 "fieldSourceConfigurations": "field_source_configurations", 

200 "calibrationConfigurations": "calibration_configurations", 

201 "spectralSpecConfigurations": "spectral_spec_configurations", 

202 "performanceParameterConfigurations": "performance_parameter_configurations", 

203 } 

204 for json_field, attribute_name in json_collections.items(): 

205 collection = getattr(sfc, attribute_name) 

206 for fs_config in cap_config[json_field]: 

207 config = next( 

208 (pc for pc in collection if pc.parameter.lower() == fs_config["parameter"].lower()), None 

209 ) 

210 if not config: 

211 config = ParameterConfiguration( 

212 solicitation_facility_capability=sfc, parameter=fs_config["parameter"] 

213 ) 

214 collection.append(config) 

215 config.interactivity_mode = fs_config["interactivityMode"] 

216 if "lowerBound" in fs_config.keys(): 

217 config.lower_bound = fs_config["lowerBound"] 

218 if "upperBound" in fs_config.keys(): 

219 config.upper_bound = fs_config["upperBound"] 

220 if "fieldDefault" in fs_config.keys(): 

221 config.field_default = fs_config["fieldDefault"] 

222 if "fieldType" in fs_config.keys() and fs_config["fieldType"]: 

223 config.field_type = InputUnitGroups(fs_config["fieldType"]) 

224 

225 frontend_configurations = [ 

226 FrontendConfiguration( 

227 solicitation_facility_capability=sfc, 

228 frontend=fes[fe_config["frontendName"]], 

229 tuning_range_min=fe_config["tuningRange"]["min"], 

230 tuning_range_max=fe_config["tuningRange"]["max"], 

231 weather_conditions=fe_config["weatherCondition"], 

232 ) 

233 for fe_config in cap_config["frontendConfigurations"] 

234 ] 

235 

236 backend_configurations = [ 

237 BackendConfiguration( 

238 solicitation_facility_capability=sfc, 

239 backend=bes[be_config["backendName"]], 

240 ) 

241 for be_config in cap_config["backendConfigurations"] 

242 ] 

243 

244 array_configuration_configurations = ( 

245 [ 

246 ArrayConfigurationConfiguration( 

247 solicitation_facility_capability=sfc, 

248 array_configuration=acs[ac_config["arrayConfigurationName"]], 

249 baseline_min=ac_config["baseline"]["min"], 

250 baseline_max=ac_config["baseline"]["max"], 

251 ) 

252 for ac_config in cap_config["arrayConfigurationConfigurations"] 

253 ] 

254 if "arrayConfigurationConfigurations" in cap_config.keys() 

255 else [] 

256 ) 

257 

258 solicitation.solicitation_facility_capabilities.append(sfc) 

259 else: 

260 repo.session.add(solicitation) 

261 

262 validation_errors = validate_science_categories(solicitation_config["scienceCategories"], repo) 

263 if len(validation_errors): 

264 raise ValueError( 

265 f"Science category validation errors found in solicitation config: {', '.join(validation_errors)}" 

266 ) 

267 

268 for scicat in solicitation_config["scienceCategories"]: 

269 science_categories.append(repo.science_category_repo.by_name(scicat["name"])) 

270 

271 if "proposalClasses" in solicitation_config.keys(): 

272 for pc_json in solicitation_config["proposalClasses"]: 

273 spc = SolicitationProposalClass() 

274 spc.proposal_class = repo.proposal_class_repo.by_name(pc_json["proposalClassName"]) 

275 spc.title_max_characters = pc_json["titleMaxCharacters"] 

276 spc.title_min_characters = pc_json["titleMinCharacters"] 

277 spc.abstract_max_characters = pc_json["abstractMaxCharacters"] 

278 spc.abstract_min_characters = pc_json["abstractMinCharacters"] 

279 spc.scientific_justification_max_pages = pc_json["scientificJustificationMaxPages"] 

280 spc.scientific_justification_min_pages = pc_json["scientificJustificationMinPages"] 

281 solicitation.solicitation_proposal_classes.append(spc) 

282 

283 solicitation.science_categories = science_categories 

284 return solicitation 

285 

286 

287def validate_science_categories(science_categories: list, repo: ORMRepository) -> set[str]: 

288 """Validate the science categories supplied in a solicitation configuration 

289 

290 :param science_categories: list of science categories to validate 

291 :param repo: the repository to use 

292 :returns: The complete set of errors that need to be addressed for science categories to match against master list 

293 of science categories 

294 """ 

295 validation_errors: set[str] = set() 

296 

297 for scicat in science_categories: 

298 scicat_name = scicat["name"] 

299 try: 

300 repo.science_category_repo.by_name(scicat["name"]) 

301 except ValueError as e: 

302 validation_errors.add(str(e)) 

303 

304 return validation_errors