diff --git a/Atlas.ManualTesting.Common/FileReader.cs b/Atlas.ManualTesting.Common/FileReader.cs new file mode 100644 index 000000000..6e8c0152b --- /dev/null +++ b/Atlas.ManualTesting.Common/FileReader.cs @@ -0,0 +1,64 @@ +using CsvHelper; + +namespace Atlas.ManualTesting.Common +{ + public interface IFileReader + { + Task> ReadAllLines(string delimiter, string filePath); + IAsyncEnumerable ReadAsync(string delimiter, string filePath); + } + + public class FileReader : IFileReader + { + public async Task> ReadAllLines(string delimiter, string filePath) + { + FileChecks(filePath); + + await using var stream = File.OpenRead(filePath); + using var reader = new StreamReader(stream); + using var csv = new CsvReader(reader); + + csv.Configuration.Delimiter = delimiter; + csv.Configuration.HeaderValidated = null; + csv.Configuration.MissingFieldFound = null; + csv.Configuration.TypeConverterOptionsCache.GetOptions().NullValues.Add(""); + + return csv.GetRecords().ToList(); + } + + public async IAsyncEnumerable ReadAsync(string delimiter, string filePath) + { + FileChecks(filePath); + + await using var stream = File.OpenRead(filePath); + using var reader = new StreamReader(stream); + using var csv = new CsvReader(reader); + + csv.Configuration.Delimiter = delimiter; + csv.Configuration.HeaderValidated = null; + csv.Configuration.MissingFieldFound = null; + csv.Configuration.TypeConverterOptionsCache.GetOptions().NullValues.Add(""); + + await csv.ReadAsync(); + csv.ReadHeader(); + + while (await csv.ReadAsync()) + { + yield return csv.GetRecord(); + } + } + + private static void FileChecks(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentNullException(nameof(filePath)); + } + + if (!File.Exists(filePath)) + { + throw new ArgumentException($"File not found at {filePath}."); + } + } + } +} \ No newline at end of file diff --git a/Atlas.ManualTesting.Common/SubjectImport/SubjectInfoReader.cs b/Atlas.ManualTesting.Common/SubjectImport/SubjectInfoReader.cs deleted file mode 100644 index da5ce9d9b..000000000 --- a/Atlas.ManualTesting.Common/SubjectImport/SubjectInfoReader.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CsvHelper; - -namespace Atlas.ManualTesting.Common.SubjectImport -{ - public interface ISubjectInfoReader - { - Task> Read(string filePath); - } - - public class SubjectInfoReader : ISubjectInfoReader - { - public async Task> Read(string filePath) - { - if (string.IsNullOrEmpty(filePath)) - { - throw new ArgumentNullException(nameof(filePath)); - } - - if (!File.Exists(filePath)) - { - throw new ArgumentException($"File not found at {filePath}."); - } - - await using var stream = File.OpenRead(filePath); - using var reader = new StreamReader(stream); - using var csv = new CsvReader(reader); - - csv.Configuration.Delimiter = ";"; - csv.Configuration.HeaderValidated = null; - csv.Configuration.MissingFieldFound = null; - csv.Configuration.TypeConverterOptionsCache.GetOptions().NullValues.Add(""); - - return csv.GetRecords().ToList(); - } - } -} diff --git a/Atlas.ManualTesting/DependencyInjection/ServiceConfiguration.cs b/Atlas.ManualTesting/DependencyInjection/ServiceConfiguration.cs index 71368740e..83a0d0894 100644 --- a/Atlas.ManualTesting/DependencyInjection/ServiceConfiguration.cs +++ b/Atlas.ManualTesting/DependencyInjection/ServiceConfiguration.cs @@ -5,10 +5,11 @@ using Atlas.Common.Utils.Extensions; using Atlas.DonorImport.Data.Repositories; using Atlas.DonorImport.ExternalInterface.Models; -using Atlas.ManualTesting.Common.SubjectImport; +using Atlas.ManualTesting.Common; using Atlas.ManualTesting.Services; using Atlas.ManualTesting.Services.Scoring; using Atlas.ManualTesting.Services.ServiceBus; +using Atlas.ManualTesting.Services.WmdaConsensusResults; using Atlas.ManualTesting.Settings; using Atlas.MatchingAlgorithm.Common.Models; using Microsoft.Extensions.DependencyInjection; @@ -33,6 +34,7 @@ public static void RegisterServices(this IServiceCollection services) private static void RegisterSettings(this IServiceCollection services) { + services.RegisterAsOptions("HlaMetadataDictionary"); services.RegisterAsOptions("MessagingServiceBus"); services.RegisterAsOptions("Matching"); services.RegisterAsOptions("Matching:DonorManagement"); @@ -84,9 +86,12 @@ Func fetchDonorManagementSettings services.AddScoped(); - services.AddScoped(); + services.AddScoped(typeof(IFileReader<>), typeof(FileReader<>)); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void RegisterDatabaseServices( diff --git a/Atlas.ManualTesting/Functions/WmdaConsensusDatasetFunctions.cs b/Atlas.ManualTesting/Functions/WmdaConsensusDatasetFunctions.cs index c4e30a309..aa8d5999f 100644 --- a/Atlas.ManualTesting/Functions/WmdaConsensusDatasetFunctions.cs +++ b/Atlas.ManualTesting/Functions/WmdaConsensusDatasetFunctions.cs @@ -5,6 +5,7 @@ using Atlas.Common.Public.Models.GeneticData; using Atlas.ManualTesting.Models; using Atlas.ManualTesting.Services.Scoring; +using Atlas.ManualTesting.Services.WmdaConsensusResults; using AzureFunctions.Extensions.Swashbuckle.Attribute; using Microsoft.AspNetCore.Http; using Microsoft.Azure.WebJobs; @@ -19,10 +20,14 @@ namespace Atlas.ManualTesting.Functions public class WmdaConsensusDatasetFunctions { private readonly IScoreRequestProcessor scoreRequestProcessor; + private readonly IWmdaDiscrepantResultsReporter wmdaDiscrepantResultsReporter; - public WmdaConsensusDatasetFunctions(IScoreRequestProcessor scoreRequestProcessor) + public WmdaConsensusDatasetFunctions( + IScoreRequestProcessor scoreRequestProcessor, + IWmdaDiscrepantResultsReporter wmdaDiscrepantResultsReporter) { this.scoreRequestProcessor = scoreRequestProcessor; + this.wmdaDiscrepantResultsReporter = wmdaDiscrepantResultsReporter; } [FunctionName(nameof(ProcessWmdaConsensusDataset_Exercise1))] @@ -57,6 +62,16 @@ await scoreRequestProcessor.ProcessScoreRequest(new ScoreRequestProcessorInput }); } + [FunctionName(nameof(ReportDiscrepantResults_Exercise1))] + public async Task ReportDiscrepantResults_Exercise1( + [RequestBodyType(typeof(ReportDiscrepanciesRequest), nameof(ReportDiscrepanciesRequest))] + [HttpTrigger(AuthorizationLevel.Function, "post")] + HttpRequest request) + { + var importAndCompareRequest = JsonConvert.DeserializeObject(await new StreamReader(request.Body).ReadToEndAsync()); + await wmdaDiscrepantResultsReporter.ReportDiscrepantResults(importAndCompareRequest); + } + private static ScoringCriteria BuildThreeLocusScoringCriteria() { return new ScoringCriteria diff --git a/Atlas.ManualTesting/Models/ImportedSubjectExtensions.cs b/Atlas.ManualTesting/Models/ImportedSubjectExtensions.cs index 49f5c0921..e1eba3247 100644 --- a/Atlas.ManualTesting/Models/ImportedSubjectExtensions.cs +++ b/Atlas.ManualTesting/Models/ImportedSubjectExtensions.cs @@ -1,12 +1,11 @@ using Atlas.Common.Public.Models.GeneticData.PhenotypeInfo; -using Atlas.Common.Public.Models.GeneticData.PhenotypeInfo.TransferModels; using Atlas.ManualTesting.Common.SubjectImport; namespace Atlas.ManualTesting.Models { internal static class ImportedSubjectExtensions { - public static PhenotypeInfoTransfer ToPhenotypeInfoTransfer(this ImportedSubject subject) + public static PhenotypeInfo ToPhenotypeInfo(this ImportedSubject subject) { return new PhenotypeInfo( valueA_1: subject.A_1, @@ -18,8 +17,7 @@ public static PhenotypeInfoTransfer ToPhenotypeInfoTransfer(this Importe valueDqb1_1: subject.DQB1_1, valueDqb1_2: subject.DQB1_2, valueDrb1_1: subject.DRB1_1, - valueDrb1_2: subject.DRB1_2) - .ToPhenotypeInfoTransfer(); + valueDrb1_2: subject.DRB1_2); } } } diff --git a/Atlas.ManualTesting/Models/ReportDiscrepanciesRequest.cs b/Atlas.ManualTesting/Models/ReportDiscrepanciesRequest.cs new file mode 100644 index 000000000..786474ed8 --- /dev/null +++ b/Atlas.ManualTesting/Models/ReportDiscrepanciesRequest.cs @@ -0,0 +1,25 @@ +namespace Atlas.ManualTesting.Models +{ + public class ReportDiscrepanciesRequest + { + /// + /// Path to file with consensus results + /// + public string ConsensusFilePath { get; set; } + + /// + /// Path to file with Atlas results that should be compared to the + /// + public string ResultsFilePath { get; set; } + + /// + /// Path to file containing patient HLA + /// + public string PatientFilePath { get; set; } + + /// + /// Path to file containing donor HLA + /// + public string DonorFilePath { get; set; } + } +} \ No newline at end of file diff --git a/Atlas.ManualTesting/Models/WmdaConsensusResultsFile.cs b/Atlas.ManualTesting/Models/WmdaConsensusResultsFile.cs index 586917f0b..d6048420e 100644 --- a/Atlas.ManualTesting/Models/WmdaConsensusResultsFile.cs +++ b/Atlas.ManualTesting/Models/WmdaConsensusResultsFile.cs @@ -9,13 +9,20 @@ public class WmdaConsensusResultsFile { public string PatientId { get; set; } public string DonorId { get; set; } - public int? MismatchCountAtA { get; set; } - public int? MismatchCountAtB { get; set; } - public int? MismatchCountAtDrb1 { get; set; } + public string MismatchCountAtA { get; set; } + public string MismatchCountAtB { get; set; } + public string MismatchCountAtDrb1 { get; set; } + + /// + /// Empty constructor needed for reading results from files + /// + public WmdaConsensusResultsFile() + { + } public WmdaConsensusResultsFile(string patientId, string donorId, ScoringResult result) { - static int? CountMismatches(LocusSearchResult locusResult) => 2 - locusResult.MatchCount; + static string CountMismatches(LocusSearchResult locusResult) => $"{2 - locusResult.MatchCount}"; PatientId = patientId; DonorId = donorId; @@ -32,19 +39,19 @@ public override string ToString() public class WmdaConsensusResultsFileSetTwo : WmdaConsensusResultsFile { - public int AntigenMismatchCountAtA { get; set; } - public int AntigenMismatchCountAtB { get; set; } - public int AntigenMismatchCountAtDrb1 { get; set; } + public string AntigenMismatchCountAtA { get; set; } + public string AntigenMismatchCountAtB { get; set; } + public string AntigenMismatchCountAtDrb1 { get; set; } public WmdaConsensusResultsFileSetTwo(string patientId, string donorId, ScoringResult result) : base(patientId, donorId, result) { - static int CountAntigenMismatches(LocusSearchResult locusResult) + static string CountAntigenMismatches(LocusSearchResult locusResult) { return new List { locusResult.ScoreDetailsAtPositionOne.IsAntigenMatch, locusResult.ScoreDetailsAtPositionTwo.IsAntigenMatch - }.Count(x => x.HasValue && !x.Value); + }.Count(x => x.HasValue && !x.Value).ToString(); } AntigenMismatchCountAtA = CountAntigenMismatches(result.SearchResultAtLocusA); diff --git a/Atlas.ManualTesting/Services/ConvertHlaRequester.cs b/Atlas.ManualTesting/Services/ConvertHlaRequester.cs new file mode 100644 index 000000000..417277aa1 --- /dev/null +++ b/Atlas.ManualTesting/Services/ConvertHlaRequester.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using System.Threading.Tasks; +using Atlas.Common.Public.Models.GeneticData; +using Atlas.HlaMetadataDictionary.ExternalInterface.Models; +using Atlas.ManualTesting.Settings; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Polly; + +namespace Atlas.ManualTesting.Services +{ + /// + /// Copied , + /// instead of moving the model to the MatchingAlgorithm.Client.Models project, + /// to avoid breaking client/interface changes that would come from moving . + /// + public class ConvertHlaRequest + { + public Locus Locus { get; set; } + public string HlaName { get; set; } + public TargetHlaCategory TargetHlaCategory { get; set; } + + public override string ToString() + { + return $"convert {Locus},{HlaName} to {TargetHlaCategory}"; + } + } + + public interface IConvertHlaRequester + { + Task> ConvertHla(ConvertHlaRequest request); + } + + internal class ConvertHlaRequester : IConvertHlaRequester + { + private const string FailedRequestPrefix = "Failed to"; + private static readonly HttpClient HttpRequestClient = new(); + private readonly HlaMetadataDictionarySettings hlaMetadataDictionarySettings; + + public ConvertHlaRequester(IOptions settings) + { + hlaMetadataDictionarySettings = settings.Value; + } + + public async Task> ConvertHla(ConvertHlaRequest request) + { + if (request?.HlaName is null) + { + throw new ArgumentException("ConvertHla request is missing required data."); + } + + return await ExecuteHlaConversionRequest(request); + } + + private async Task> ExecuteHlaConversionRequest(ConvertHlaRequest request) + { + var retryPolicy = Policy.Handle().RetryAsync(10); + + var requestResponse = await retryPolicy.ExecuteAndCaptureAsync(async () => await SendHlaConversionRequest(request)); + + return requestResponse.Outcome == OutcomeType.Successful + ? requestResponse.Result + : new[] { $"{FailedRequestPrefix} {request}" }; + } + + private async Task> SendHlaConversionRequest(ConvertHlaRequest request) + { + try + { + var response = await HttpRequestClient.PostAsync( + hlaMetadataDictionarySettings.ConvertHlaRequestUrl, new StringContent(JsonConvert.SerializeObject(request))); + response.EnsureSuccessStatusCode(); + var hlaConversionResult = JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); + + Debug.WriteLine($"Result received: {request}"); + + return hlaConversionResult; + } + catch (Exception ex) + { + Debug.WriteLine($"{FailedRequestPrefix} {request}. Details: {ex.Message}. Re-attempting until success or re-attempt count reached."); + throw; + } + } + } +} \ No newline at end of file diff --git a/Atlas.ManualTesting/Services/Scoring/ScoreBatchRequester.cs b/Atlas.ManualTesting/Services/Scoring/ScoreBatchRequester.cs index 5686626fd..cfb7cba6d 100644 --- a/Atlas.ManualTesting/Services/Scoring/ScoreBatchRequester.cs +++ b/Atlas.ManualTesting/Services/Scoring/ScoreBatchRequester.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Threading.Tasks; using Atlas.Client.Models.Search.Requests; +using Atlas.Common.Public.Models.GeneticData.PhenotypeInfo.TransferModels; using Atlas.ManualTesting.Common.SubjectImport; using Atlas.ManualTesting.Models; using Atlas.ManualTesting.Settings; @@ -26,7 +27,6 @@ public class ScoreBatchRequest public interface IScoreBatchRequester { - /// Id for Verification Run Task> ScoreBatch(ScoreBatchRequest request); } @@ -75,7 +75,7 @@ private static BatchScoringRequest BuildScoreBatchRequest(ScoreBatchRequest requ { return new BatchScoringRequest { - PatientHla = request.Patient.ToPhenotypeInfoTransfer(), + PatientHla = request.Patient.ToPhenotypeInfo().ToPhenotypeInfoTransfer(), DonorsHla = request.Donors.Select(ToIdentifiedDonorHla), ScoringCriteria = request.ScoringCriteria }; @@ -83,7 +83,7 @@ private static BatchScoringRequest BuildScoreBatchRequest(ScoreBatchRequest requ private static IdentifiedDonorHla ToIdentifiedDonorHla(ImportedSubject donor) { - var donorHla = donor.ToPhenotypeInfoTransfer(); + var donorHla = donor.ToPhenotypeInfo().ToPhenotypeInfoTransfer(); return new IdentifiedDonorHla { diff --git a/Atlas.ManualTesting/Services/Scoring/ScoreRequestProcessor.cs b/Atlas.ManualTesting/Services/Scoring/ScoreRequestProcessor.cs index 68bbb1d0b..78f3f250e 100644 --- a/Atlas.ManualTesting/Services/Scoring/ScoreRequestProcessor.cs +++ b/Atlas.ManualTesting/Services/Scoring/ScoreRequestProcessor.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Atlas.Client.Models.Search.Requests; +using Atlas.ManualTesting.Common; using Atlas.ManualTesting.Common.SubjectImport; using Atlas.ManualTesting.Models; using Atlas.MatchingAlgorithm.Client.Models.Scoring; @@ -36,20 +37,21 @@ internal class ScoreRequestProcessor : IScoreRequestProcessor { // Value should be large enough for good throughput, but small enough to avoid very large http request payload. private const int DonorBatchSize = 1000; - private readonly ISubjectInfoReader subjectInfoReader; + private const string FileDelimiter = ";"; + private readonly IFileReader subjectReader; private readonly IScoreBatchRequester scoreBatchRequester; - public ScoreRequestProcessor(ISubjectInfoReader subjectInfoReader, IScoreBatchRequester scoreBatchRequester) + public ScoreRequestProcessor(IFileReader subjectReader, IScoreBatchRequester scoreBatchRequester) { - this.subjectInfoReader = subjectInfoReader; + this.subjectReader = subjectReader; this.scoreBatchRequester = scoreBatchRequester; } /// public async Task ProcessScoreRequest(ScoreRequestProcessorInput input) { - var patients = await subjectInfoReader.Read(input.ImportAndScoreRequest.PatientFilePath); - var donors = await subjectInfoReader.Read(input.ImportAndScoreRequest.DonorFilePath); + var patients = await subjectReader.ReadAllLines(FileDelimiter, input.ImportAndScoreRequest.PatientFilePath); + var donors = await subjectReader.ReadAllLines(FileDelimiter, input.ImportAndScoreRequest.DonorFilePath); if (patients.Count == 0 || donors.Count == 0) { diff --git a/Atlas.ManualTesting/Services/WmdaConsensusResults/WmdaDiscrepantResultsReporter.cs b/Atlas.ManualTesting/Services/WmdaConsensusResults/WmdaDiscrepantResultsReporter.cs new file mode 100644 index 000000000..02b8e47a9 --- /dev/null +++ b/Atlas.ManualTesting/Services/WmdaConsensusResults/WmdaDiscrepantResultsReporter.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Atlas.Common.Caching; +using Atlas.Common.Public.Models.GeneticData; +using Atlas.Common.Public.Models.GeneticData.PhenotypeInfo; +using Atlas.HlaMetadataDictionary.ExternalInterface.Models; +using Atlas.ManualTesting.Models; +using LazyCache; + +namespace Atlas.ManualTesting.Services.WmdaConsensusResults +{ + public interface IWmdaDiscrepantResultsReporter + { + /// + /// Compares WMDA consensus file to Atlas results file to identify discrepant mismatch counts, + /// and writes them to a text file within the same directory as the Atlas results file. + /// + Task ReportDiscrepantResults(ReportDiscrepanciesRequest request); + } + + internal class WmdaDiscrepantResultsReporter : IWmdaDiscrepantResultsReporter + { + private readonly IAppCache pGroupsCache; + private readonly IWmdaResultsComparer resultsComparer; + private readonly IConvertHlaRequester hlaConverter; + + private record CommonPGroupInfo(string PatientHla, string DonorHla, IReadOnlyCollection CommonPGroups); + private record AnalysedDiscrepancy( + Locus Locus, + LocusInfo PatientHla, + LocusInfo DonorHla, + LocusInfo CommonPGroups, + IReadOnlyCollection MismatchedCounts); + + public WmdaDiscrepantResultsReporter( + IWmdaResultsComparer resultsComparer, + ITransientCacheProvider cacheProvider, + IConvertHlaRequester hlaConverter) + { + this.resultsComparer = resultsComparer; + pGroupsCache = cacheProvider.Cache; + this.hlaConverter = hlaConverter; + } + + /// + public async Task ReportDiscrepantResults(ReportDiscrepanciesRequest request) + { + var discrepantResults = await resultsComparer.GetDiscrepantResults(request); + var groupedResults = discrepantResults.GroupBy(x => new { x.Locus, x.PatientHla, x.DonorHla }); + + var analysedDiscrepancies = new List(); + foreach (var discrepancy in groupedResults) + { + var locus = discrepancy.Key.Locus; + var patientHla = discrepancy.Key.PatientHla; + var donorHla = discrepancy.Key.DonorHla; + var commonPGroups = await GetCommonPGroupInfo(locus, patientHla, donorHla); + + analysedDiscrepancies.Add(new AnalysedDiscrepancy( + locus, patientHla, donorHla, commonPGroups, discrepancy.Select(x => x.MismatchCountDetails).ToList())); + } + + await WriteAnalysedDiscrepanciesToFile(request.ResultsFilePath, analysedDiscrepancies); + } + + public async Task> GetOrAddPGroups(Locus locus, string hlaName) + { + var cacheKey = $"l{locus};hla{hlaName}"; + return await pGroupsCache.GetOrAdd(cacheKey, () => hlaConverter.ConvertHla(new ConvertHlaRequest + { + Locus = locus, + HlaName = hlaName, + TargetHlaCategory = TargetHlaCategory.PGroup + })); + } + + private async Task> GetCommonPGroupInfo( + Locus locus, + LocusInfo patientHla, + LocusInfo donorHla) + { + Task>> ConvertToPGroups(LocusInfo typing) => typing.MapAsync(hla => GetOrAddPGroups(locus, hla)); + var patientPGroups = await ConvertToPGroups(patientHla); + var donorPGroups = await ConvertToPGroups(donorHla); + + var (isDirectOrientation, commonPGroups) = CalculateCommonPGroups(patientPGroups, donorPGroups); + + return new LocusInfo( + new CommonPGroupInfo(patientHla.Position1, isDirectOrientation ? donorHla.Position1 : donorHla.Position2, commonPGroups.Position1), + new CommonPGroupInfo(patientHla.Position2, isDirectOrientation ? donorHla.Position2 : donorHla.Position1, commonPGroups.Position2) + ); + } + + /// (Is direct orientation?, common P groups) + private static (bool, LocusInfo>) CalculateCommonPGroups( + LocusInfo> patientPGroups, + LocusInfo> donorPGroups) + { + var directCommonPGroups = CommonPGroupsInDirectOrientation(patientPGroups, donorPGroups); + var crossCommonPGroups = CommonPGroupsInCrossOrientation(patientPGroups, donorPGroups); + + static bool BothPositionsHaveCommonPGroups(LocusInfo> commonPGroups) => + commonPGroups.Position1.Any() && commonPGroups.Position2.Any(); + + if (BothPositionsHaveCommonPGroups(directCommonPGroups)) + { + return (true, directCommonPGroups); + } + + if (BothPositionsHaveCommonPGroups(crossCommonPGroups)) + { + return (false, crossCommonPGroups); + } + + static int TotalPGroupCount(LocusInfo> pGroups) => pGroups.Position1.Count + pGroups.Position2.Count; + return TotalPGroupCount(directCommonPGroups) >= TotalPGroupCount(crossCommonPGroups) + ? (true, directCommonPGroups) + : (false, crossCommonPGroups); + } + + private static LocusInfo> CommonPGroupsInDirectOrientation( + LocusInfo> patientPGroups, + LocusInfo> donorPGroups) + { + return new LocusInfo>( + CommonPGroups(patientPGroups.Position1, donorPGroups.Position1), + CommonPGroups(patientPGroups.Position2, donorPGroups.Position2) + ); + } + + private static LocusInfo> CommonPGroupsInCrossOrientation( + LocusInfo> patientPGroups, + LocusInfo> donorPGroups) + { + return new LocusInfo>( + CommonPGroups(patientPGroups.Position1, donorPGroups.Position2), + CommonPGroups(patientPGroups.Position2, donorPGroups.Position1) + ); + } + + private static IReadOnlyCollection CommonPGroups(IEnumerable patientPGroups, IEnumerable donorPGroups) + { + return patientPGroups.Intersect(donorPGroups).ToList(); + } + + private static async Task WriteAnalysedDiscrepanciesToFile(string resultsFilePath, IEnumerable analysedDiscrepancies) + { + var outputPath = + $"{Path.GetDirectoryName(resultsFilePath)}/" + + $"{Path.GetFileNameWithoutExtension(resultsFilePath)}-discrepancies.txt"; + + var contents = BuildFileContents(analysedDiscrepancies); + const string headerText = "Locus;Patient Hla;Donor Hla;Consensus Count;Atlas Count;Common PGroups;PDP Count;PDP Ids"; + await File.WriteAllLinesAsync(outputPath, new[] { headerText }.Concat(contents)); + } + + private static IEnumerable BuildFileContents(IEnumerable analysedDiscrepancies) + { + static string PGroupsToString(CommonPGroupInfo info) + { + if (!info.CommonPGroups.Any()) + { + return "_"; + } + + const int maxLength = 24; + var concatPGroups = ConcatenateStrings(info.CommonPGroups); + var pGroupStr = concatPGroups.Length > maxLength ? $"{concatPGroups[..maxLength]}..." : concatPGroups; + return $"([{info.PatientHla}-{info.DonorHla}]{pGroupStr})"; + } + + static string ConcatenateStrings(IEnumerable strings) => string.Join(",", strings); + + return analysedDiscrepancies.Select(d => + $"{d.Locus};" + + $"{d.PatientHla.Position1} + {d.PatientHla.Position2};" + + $"{d.DonorHla.Position1} + {d.DonorHla.Position2};" + + $"{d.MismatchedCounts.First().ConsensusMismatchCount};" + + $"{d.MismatchedCounts.First().AtlasMismatchCount};" + + $"{PGroupsToString(d.CommonPGroups.Position1)} + {PGroupsToString(d.CommonPGroups.Position2)};" + + $"{d.MismatchedCounts.Count};" + + $"{ConcatenateStrings(d.MismatchedCounts.Select(mc => $"{mc.PatientId}:{mc.DonorId}"))}" + ); + } + } +} \ No newline at end of file diff --git a/Atlas.ManualTesting/Services/WmdaConsensusResults/WmdaResultsComparer.cs b/Atlas.ManualTesting/Services/WmdaConsensusResults/WmdaResultsComparer.cs new file mode 100644 index 000000000..5df793a84 --- /dev/null +++ b/Atlas.ManualTesting/Services/WmdaConsensusResults/WmdaResultsComparer.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Atlas.Common.Public.Models.GeneticData; +using Atlas.Common.Public.Models.GeneticData.PhenotypeInfo; +using Atlas.ManualTesting.Common; +using Atlas.ManualTesting.Common.SubjectImport; +using Atlas.ManualTesting.Models; + +namespace Atlas.ManualTesting.Services.WmdaConsensusResults +{ + public class WmdaComparerResult + { + public Locus Locus { get; set; } + public LocusInfo PatientHla { get; set; } + public LocusInfo DonorHla { get; set; } + public MismatchCountDetails MismatchCountDetails { get; set; } + } + + public class MismatchCountDetails + { + public string PatientId { get; set; } + public string DonorId { get; set; } + public string ConsensusMismatchCount { get; set; } + public string AtlasMismatchCount { get; set; } + } + + public interface IWmdaResultsComparer + { + /// + /// Requires the consensus file and results file to both have the same number of lines and be ordered in the same way, by patient id and donor id. + /// + Task> GetDiscrepantResults(ReportDiscrepanciesRequest request); + } + + internal class WmdaResultsComparer : IWmdaResultsComparer + { + private const string FileDelimiter = ";"; + private readonly IFileReader resultsFileReader; + private readonly IFileReader subjectFileReader; + private record CombinedScoringResult(WmdaConsensusResultsFile Consensus, WmdaConsensusResultsFile Result); + + public WmdaResultsComparer( + IFileReader resultsFileReader, + IFileReader subjectFileReader) + { + this.resultsFileReader = resultsFileReader; + this.subjectFileReader = subjectFileReader; + } + + /// + public async Task> GetDiscrepantResults(ReportDiscrepanciesRequest request) + { + var comparerResults = new List(); + + var combinedResults = GetCombinedResults(request); + var patients = await ImportSubjects(request.PatientFilePath); + var donors = await ImportSubjects(request.DonorFilePath); + + await foreach (var combination in combinedResults) + { + var consensusStr = combination.Consensus.ToString(); + var resultStr = combination.Result.ToString(); + if (string.Equals(consensusStr, resultStr)) + { + continue; + } + + var patient = patients[combination.Consensus.PatientId].ToPhenotypeInfo(); + var donor = donors[combination.Consensus.DonorId].ToPhenotypeInfo(); + + var lociWithDiscrepantCounts = GetLociWithDifferentMismatchCounts(combination.Consensus, combination.Result); + + comparerResults.AddRange(lociWithDiscrepantCounts.Select(countDetails => new WmdaComparerResult + { + Locus = countDetails.Item1, + PatientHla = patient.GetLocus(countDetails.Item1), + DonorHla = donor.GetLocus(countDetails.Item1), + MismatchCountDetails = countDetails.Item2 + })); + } + + return comparerResults; + } + + private IAsyncEnumerable GetCombinedResults(ReportDiscrepanciesRequest request) + { + var consensus = resultsFileReader.ReadAsync(FileDelimiter, request.ConsensusFilePath); + var results = resultsFileReader.ReadAsync(FileDelimiter, request.ResultsFilePath); + return consensus.Zip(results, (c, r) => new CombinedScoringResult(c, r)); + } + + private async Task> ImportSubjects(string filePath) + { + return (await subjectFileReader.ReadAllLines(FileDelimiter, filePath)).ToDictionary(s => s.ID, s => s); + } + + private static IEnumerable<(Locus, MismatchCountDetails)> GetLociWithDifferentMismatchCounts(WmdaConsensusResultsFile consensus, WmdaConsensusResultsFile atlasResult) + { + var details = new List<(Locus, MismatchCountDetails)>(); + + void CheckLocusMismatchCounts(string consensusMismatchCount, string resultMismatchCount, Locus locus) + { + if (consensusMismatchCount != resultMismatchCount) details.Add(new ValueTuple(locus, + new MismatchCountDetails + { + PatientId = consensus.PatientId, + DonorId = consensus.DonorId, + ConsensusMismatchCount = consensusMismatchCount, + AtlasMismatchCount = resultMismatchCount + })); + } + + CheckLocusMismatchCounts(consensus.MismatchCountAtA, atlasResult.MismatchCountAtA, Locus.A); + CheckLocusMismatchCounts(consensus.MismatchCountAtB, atlasResult.MismatchCountAtB, Locus.B); + CheckLocusMismatchCounts(consensus.MismatchCountAtDrb1, atlasResult.MismatchCountAtDrb1, Locus.Drb1); + + return details; + } + } +} \ No newline at end of file diff --git a/Atlas.ManualTesting/Settings/HlaMetadataDictionarySettings.cs b/Atlas.ManualTesting/Settings/HlaMetadataDictionarySettings.cs new file mode 100644 index 000000000..8e2f7f4ab --- /dev/null +++ b/Atlas.ManualTesting/Settings/HlaMetadataDictionarySettings.cs @@ -0,0 +1,7 @@ +namespace Atlas.ManualTesting.Settings +{ + internal class HlaMetadataDictionarySettings + { + public string ConvertHlaRequestUrl { get; set; } + } +} \ No newline at end of file diff --git a/Atlas.ManualTesting/local.settings.template.json b/Atlas.ManualTesting/local.settings.template.json index 1cd2c3ea6..3ded3bacd 100644 --- a/Atlas.ManualTesting/local.settings.template.json +++ b/Atlas.ManualTesting/local.settings.template.json @@ -3,6 +3,8 @@ "Values": { "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "HlaMetadataDictionary:ConvertHlaRequestUrl": "override-this", + "MessagingServiceBus:ConnectionString": "override-this", "Matching:RequestsTopic": "matching-requests", @@ -10,7 +12,7 @@ "Matching:DonorManagement:Topic": "updated-searchable-donors", "Matching:DonorManagement:Subscription": "audit", - "Scoring:ScoreBatchRequestUrl": "override-this", + "Scoring:ScoreBatchRequestUrl": "override-this", "Search:ResultsTopic": "search-results-ready", "Search:ResultsSubscription": "audit" diff --git a/Atlas.MatchPrediction.Test.Validation/DependencyInjection/ServiceConfiguration.cs b/Atlas.MatchPrediction.Test.Validation/DependencyInjection/ServiceConfiguration.cs index 34729089f..aaf8e26cd 100644 --- a/Atlas.MatchPrediction.Test.Validation/DependencyInjection/ServiceConfiguration.cs +++ b/Atlas.MatchPrediction.Test.Validation/DependencyInjection/ServiceConfiguration.cs @@ -8,6 +8,7 @@ using Atlas.MatchPrediction.ExternalInterface.DependencyInjection; using Atlas.MatchPrediction.ExternalInterface.Settings; using Atlas.Common.ApplicationInsights; +using Atlas.ManualTesting.Common; using Atlas.ManualTesting.Common.SubjectImport; namespace Atlas.MatchPrediction.Test.Validation.DependencyInjection @@ -55,7 +56,7 @@ private static void RegisterServices( Func fetchValidationAzureStorageSettings) { services.AddScoped(); - services.AddScoped(); + services.AddScoped, FileReader>(); services.AddScoped(); services.AddScoped(sp => { diff --git a/Atlas.MatchPrediction.Test.Validation/Services/SubjectInfoImporter.cs b/Atlas.MatchPrediction.Test.Validation/Services/SubjectInfoImporter.cs index 578be211f..b0af86890 100644 --- a/Atlas.MatchPrediction.Test.Validation/Services/SubjectInfoImporter.cs +++ b/Atlas.MatchPrediction.Test.Validation/Services/SubjectInfoImporter.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Atlas.Common.Utils.Extensions; +using Atlas.ManualTesting.Common; using Atlas.ManualTesting.Common.SubjectImport; using Atlas.MatchPrediction.Test.Validation.Data.Models; using Atlas.MatchPrediction.Test.Validation.Data.Repositories; @@ -20,11 +21,12 @@ public interface ISubjectInfoImporter internal class SubjectInfoImporter : ISubjectInfoImporter { - private readonly ISubjectInfoReader fileReader; + private const string FileDelimiter = ";"; + private readonly IFileReader fileReader; private readonly IValidationRepository validationRepository; private readonly ISubjectRepository subjectRepository; - public SubjectInfoImporter(ISubjectInfoReader fileReader, IValidationRepository validationRepository, ISubjectRepository subjectRepository) + public SubjectInfoImporter(IFileReader fileReader, IValidationRepository validationRepository, ISubjectRepository subjectRepository) { this.fileReader = fileReader; this.validationRepository = validationRepository; @@ -41,7 +43,7 @@ public async Task Import(ImportRequest request) private async Task ImportSubjects(string filePath, SubjectType subjectType) { - var importedSubjects = await fileReader.Read(filePath); + var importedSubjects = await fileReader.ReadAllLines(FileDelimiter, filePath); var filteredSubjects = importedSubjects .Where(s => IsTyped(s.A_1, s.A_2) && IsTyped(s.B_1, s.B_2) && IsTyped(s.DRB1_1, s.DRB1_2)) diff --git a/Atlas.sln.DotSettings b/Atlas.sln.DotSettings index 91825b664..241a91a48 100644 --- a/Atlas.sln.DotSettings +++ b/Atlas.sln.DotSettings @@ -4,6 +4,9 @@ WARNING DO_NOT_SHOW <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + True + True + True True True True