diff --git a/middle_layer/allocate/domain_layer/entities/src/allocated_science_target.py b/middle_layer/allocate/domain_layer/entities/src/allocated_science_target.py index 82a0a4f6cb7304fd695d405d2328218db94d6f3d..a019fff868a53fb4cea3ed61de52175d75f5444e 100644 --- a/middle_layer/allocate/domain_layer/entities/src/allocated_science_target.py +++ b/middle_layer/allocate/domain_layer/entities/src/allocated_science_target.py @@ -172,7 +172,7 @@ class AllocatedScienceTarget(Base): ) hardware_configuration: HardwareConfiguration = relationship(HardwareConfiguration) - requested_time: Quantity["time"] = Column("requested_time", QuantitySeconds, nullable=False) + requested_time: Quantity["time"] = Column("requested_time", QuantitySeconds, nullable=False, default=0 * u.s) calibration_strategy: CalibrationStrategy = Column( "calibration_strategy", sa.Enum(CalibrationStrategy), nullable=False diff --git a/middle_layer/allocate/domain_layer/services/src/create_allocated_science_target_list.py b/middle_layer/allocate/domain_layer/services/src/create_allocated_science_target_list.py index c91970aab806cca10f88e2b798a8779761ecef41..2c1efedbcad94529795d8a466bc460186d8024bf 100644 --- a/middle_layer/allocate/domain_layer/services/src/create_allocated_science_target_list.py +++ b/middle_layer/allocate/domain_layer/services/src/create_allocated_science_target_list.py @@ -14,6 +14,11 @@ # # You should have received a copy of the GNU General Public License # along with TTAT. If not, see <https://www.gnu.org/licenses/>. +from functools import cache + +import astropy.units as u +from astropy.units import Quantity + from allocate.domain_layer.entities.src.allocated_science_target import ( AllocatedScienceTarget, CalibrationStrategy, @@ -48,62 +53,115 @@ def create_allocated_science_targets( *, allocation_disposition: AllocationDisposition, allocation_request: AllocationRequest, repo: ORMRepository ) -> list[AllocatedScienceTarget]: + # TODO: once the unmodified code path exists, remove this line of code + return create_modified_allocated_science_targets(allocation_disposition, allocation_request) + # if any of the Observation Specifications are modified, we can build the ASTL if any(os.modified for os in allocation_request.observation_specifications): - # these are basically caches - sources = {} - hardware_configs = {} - - # create the ASTs - for os in allocation_request.observation_specifications: - os: ObservationSpecification - - for subscan in os.science_target_subscans: - # the plan here is to find or create a source, and then find or create a hardware configuration - # and then stitch them together into an AST instance - - # first the source - source = None - source_key = (subscan.target_name, subscan.right_ascension, subscan.declination) - if source_key in sources: - source = sources[source_key] - else: - # TODO: where do we find the pointing pattern? - source = Source( - name=subscan.target_name, - right_ascension=subscan.right_ascension, - declination=subscan.declination, - pointing_pattern=POINTING_PATTERNS[os.facility.facility_name], - ) - sources[source_key] = source - - # same song with hardware configurations - hardware_configuration = None - hardware_config_key = (subscan.frontend, subscan.backend) - if hardware_config_key in hardware_configs: - hardware_configuration = hardware_configs[hardware_config_key] - else: - hardware_configuration = HardwareConfiguration(frontend=subscan.frontend, backend=subscan.backend) - hardware_configuration.facility_configurations = os.facility_configurations - hardware_configs[hardware_config_key] = hardware_configuration - - # now we can create the AST - # TODO: where do we find the scheduling and calibration strategies? - ast = AllocatedScienceTarget( - allocation_disposition=allocation_disposition, - hardware_configuration=hardware_configuration, - source=source, - scheduling_strategy=SCHEDULING_STRATEGIES[os.facility.facility_name], - calibration_strategy=CALIBRATION_STRATEGIES[os.facility.facility_name], - ) - - allocation_disposition.allocated_science_targets.append(ast) - - # return the list on the AD for no particular reason - return allocation_disposition.allocated_science_targets + return create_modified_allocated_science_targets(allocation_disposition, allocation_request) else: - # in theory, we should at this point copy the STL instead - # unfortunately, as of Sprint 90, the STL is still garbage - raise NotImplementedError( - "unmodified obs-specs can not be used as we do not currently have a valid science target list to copy" - ) + return create_unmodified_allocated_science_targets(allocation_disposition, allocation_request) + + +def create_unmodified_allocated_science_targets( + allocation_disposition: AllocationDisposition, allocation_request: AllocationRequest +) -> list[AllocatedScienceTarget]: + """ + Generate the ASTs from the science targets + + :param allocation_disposition: + :param allocation_request: + :return: + """ + # in theory, we should at this point copy the STL instead + # unfortunately, as of Sprint 90, the STL is still garbage + raise NotImplementedError( + "unmodified obs-specs can not be used as we do not currently have a valid science target list to copy" + ) + + +def create_modified_allocated_science_targets( + allocation_disposition, allocation_request +) -> list[AllocatedScienceTarget]: + """ + Generate the ASTs from the observation specs and subscans. + + :param allocation_disposition: + :param allocation_request: + :return: + """ + # create the ASTs + all_asts = set() + + # look through all the observation specifications + for os in allocation_request.observation_specifications: + os: ObservationSpecification + + # The following three functions exist to support caching the sources, hardware configurations and + # allocated science targets. The caching is desired so that all of the ASTs we make will share the + # same source or hardware configuration _if_ the source's fields or hardware configuration's fields + # are the same. + # + # They are defined here with the cache annotation because if they were defined above this level, the cache + # would persist across observation specs, which is undesired. All of these entities should be unique _within_ + # an OS but not _across_ OSes. + + # Generate (or look up) a source + @cache + def source_for(target_name: str, right_ascension: Quantity["angle"], declination: Quantity["angle"]): + # TODO: Where do we find the pointing patterns? + return Source( + name=target_name, + right_ascension=right_ascension, + declination=declination, + pointing_pattern=POINTING_PATTERNS[os.facility.facility_name], + ) + + # Generate (or look up) a hardware configuration + @cache + def hardware_config_for(frontend: str, backend: str): + hwc = HardwareConfiguration(frontend=frontend, backend=backend) + hwc.facility_configurations = os.facility_configurations + return hwc + + # Generate (or look up) an allocated science target + @cache + def ast_for(source: Source, hardware_config: HardwareConfiguration): + # TODO: Where do we find the scheduling and calibration strategies? + return AllocatedScienceTarget( + allocation_disposition=allocation_disposition, + hardware_configuration=hardware_config, + source=source, + scheduling_strategy=SCHEDULING_STRATEGIES[os.facility.facility_name], + calibration_strategy=CALIBRATION_STRATEGIES[os.facility.facility_name], + requested_time=0 * u.s, + ) + + # The body of the loop is here, where we examine all of the *science* target subscans + # With each subscan, we obtain an AST using the above functions, and then aggregate onto it + # the acquisition time from the supporting subscan. After all the subscans have been accounted for, + # the requested time on the AST should be the sum of all the acquisition times for the relevant + # subscans. + # + # Because these are aggregated at the OS level, there should not be duplicate ASTs under an OS, but + # there may be duplicate ASTs between multiple OSes. + for subscan in os.science_target_subscans: + # the plan here is to find or create a source, and then find or create a hardware configuration + # and then stitch them together into an AST instance + + # obtain the (cached) source and hardware configurations + src = source_for(subscan.target_name, subscan.right_ascension, subscan.declination) + hardware_configuration = hardware_config_for(subscan.frontend, subscan.backend) + + # now we can create the AST and keep it for later + ast = ast_for(src, hardware_configuration) + all_asts.add(ast) + + # add the acquisition time from this subscan to this ast + ast.requested_time += subscan.acquisition_time if subscan.acquisition_time is None else 0 * u.s + + # add all the ASTs to the allocated science targets list + allocation_disposition.allocated_science_targets.extend(all_asts) + + # return the list on the AD for no particular reason + return allocation_disposition.allocated_science_targets diff --git a/middle_layer/allocate/domain_layer/services/test/test_create_allocated_science_target_list.py b/middle_layer/allocate/domain_layer/services/test/test_create_allocated_science_target_list.py index c2c4885632787817268f554a2748f58953348a5c..f2dc288a1d9f174bb89c8edcf39811d7f2905d94 100644 --- a/middle_layer/allocate/domain_layer/services/test/test_create_allocated_science_target_list.py +++ b/middle_layer/allocate/domain_layer/services/test/test_create_allocated_science_target_list.py @@ -25,6 +25,9 @@ from allocate.domain_layer.services.src.create_allocation_version_service import from common.application_layer.orm_repositories.src.orm_repository import ORMRepository from propose.domain_layer.entities.src.observation_specification import ObservationSpecification, ScienceTarget from propose.domain_layer.entities.src.proposal import AllocationRequest, ProposalCopy +from propose.domain_layer.services.src.observation_specification_generator_service import ( + generate_observation_specifications, +) from solicit.application_layer.services.src.close_solicitation_service import close_solicitation from solicit.domain_layer.entities.src.solicitation import ProposalProcess from testdata.application_layer.services.src.context import Context @@ -36,8 +39,8 @@ def create_context_for_test(repo: ORMRepository, requests_mock_notify_good: Mock global solicitation_id context = Context(repo.session) pp: ProposalProcess = repo.proposal_process_repo.by_name("Panel Proposal Review") - context.make_solicitation("Test", proposal_process=pp) - context.make_proposals(1, do_submit=True) + context.make_solicitation("Test", proposal_process=pp, random_seed=20) + proposals = context.make_proposals(1, do_submit=True) close_solicitation(context.solicitation, repo) context.run_all_ppr_process_steps() av = create_allocation_version( @@ -108,7 +111,6 @@ def test_unmodified_generates_astl_equal_to_stl(context): assert derived_from(science_target, allocated_science_target) -@pytest.mark.skip("Random failures ~30% of the time") def test_modified_generates_astl_from_scratch(context): ar = modify_n_allocation_dispositions(1, context) ad: AllocationDisposition = ar.allocation_dispositions[0] @@ -128,16 +130,62 @@ def test_modified_generates_astl_from_scratch(context): for obspec in ad.allocation_request.observation_specifications: obspec: ObservationSpecification - for hw_conf in obspec.hardware_configs: - # somewhere in there, we have this hardware configuration - assert (hw_conf.frontend, hw_conf.backend) in hw_configs + for subscan in obspec.science_target_subscans: + assert (subscan.frontend, subscan.backend) in hw_configs for sts in obspec.science_target_subscans: # somewhere in there, we have these sources assert (sts.right_ascension, sts.declination, sts.target_name) in sources -@pytest.mark.skip("Nothing to cleanup since other tests are skipped") +# @pytest.mark.skip("This test is not implemented yet") +def test_ensure_no_duplicate_asts(context): + ar = modify_n_allocation_dispositions(1, context) + ad: AllocationDisposition = ar.allocation_dispositions[0] + + # count how many ST subscans we have, should get an ASTL of this length + total_subscans = 0 + for obspec in ar.observation_specifications: + total_subscans += len(obspec.science_target_subscans) + + # make identical obspecs, and put one on the ar + all_scan_intents = {si.name: si for si in context.repo.scan_intent_repo.list_all()} + all_subscan_intents = {ssi.name: ssi for ssi in context.repo.subscan_intent_repo.list_all()} + obspecs_copy = generate_observation_specifications( + ar, all_subscan_intents["ON_SOURCE"], all_subscan_intents["OFF_SOURCE"], all_scan_intents["OBSERVE_TARGET"] + ) + new_obspec = obspecs_copy.pop() + # this awful block ensures SQLAlchemy can comprehend scan intents + for j, _ in enumerate(new_obspec.scans): + new_obspec.scans[j].scan_intents = [ + all_scan_intents[scan_intent.name] for scan_intent in new_obspec.scans[j].scan_intents + ] + subscans = new_obspec.scans[j].subscans + for k, _ in enumerate(subscans): + subscans[k].subscan_intent = all_subscan_intents[subscans[k].subscan_intent.name] + context.repo.session.add(new_obspec) + ar.observation_specifications.append(new_obspec) + + # now make sure we have extra subscans in the new set + total_subscans_with_duplicate_os = 0 + for obspec in ar.observation_specifications: + total_subscans_with_duplicate_os += len(obspec.science_target_subscans) + assert total_subscans_with_duplicate_os > total_subscans + # Make the ASTL and ensure it is consistent with length of original set of ST subscans + create_allocated_science_targets( + allocation_disposition=ad, allocation_request=ad.allocation_request, repo=context.repo + ) + assert len(ad.allocated_science_targets) == total_subscans + + +@pytest.mark.skip("This test is not implemented yet") +def test_ensure_asts_get_time(): + # step one is to arrange for an OS that would generate two separate additions of time on the AST + + # step two is to look at the AST and see if we got that sum + raise NotImplementedError + + def test_cleanup(context: Context, repo: ORMRepository): context.cleanup() repo.session.commit()