Convert-Pheno
view release on metacpan or search on metacpan
lib/Convert/Pheno/Audit/Terminology.pm view on Meta::CPAN
if ( $path =~ /\.gz\z/i ) {
$fh = IO::Compress::Gzip->new($path)
or die "Cannot gzip <$path>: $GzipError\n";
binmode( $fh, ':encoding(UTF-8)' );
$self->{format} = 'tsv_gzip';
}
else {
open $fh, '>:encoding(UTF-8)', $path
or die "Cannot write terminology audit <$path>: $!\n";
$self->{format} = 'tsv';
}
print {$fh} join( "\t", @COLUMNS ), "\n";
$self->{fh} = $fh;
return 1;
}
sub _write_tsv_row {
my ( $self, $row ) = @_;
my @values = map {
my $value = $row->{$_};
$value = sprintf '%.4f', $value
if defined $value
&& ( $_ eq 'best_candidate_score' || $_ eq 'score_margin' );
_tsv_field($value);
} @COLUMNS;
print { $self->{fh} } join( "\t", @values ), "\n";
return 1;
}
sub _open_xlsx {
my ($self) = @_;
eval { require Excel::Writer::XLSX; 1 }
or die
"Excel::Writer::XLSX is required for .xlsx terminology audits. Install the distribution dependencies and retry.\n";
my $workbook = Excel::Writer::XLSX->new( $self->{path} )
or die "Failed to create XLSX terminology audit '$self->{path}'\n";
my $summary = $workbook->add_worksheet('Summary');
my $audit = $workbook->add_worksheet('Terminology Audit');
my $header_format = $workbook->add_format(
bold => 1,
color => 'white',
bg_color => '#1F4E78',
border => 1,
valign => 'vcenter',
);
my $summary_label_format = $workbook->add_format( bold => 1 );
my $score_format = $workbook->add_format( num_format => '0.0000' );
my %row_format = (
resolved => $workbook->add_format( bg_color => '#E2F0D9' ),
review => $workbook->add_format( bg_color => '#FFF2CC' ),
unresolved => $workbook->add_format( bg_color => '#FCE4D6' ),
not_searched => $workbook->add_format( bg_color => '#E7E6E6' ),
);
my %legend_format = (
resolved => $workbook->add_format(
bold => 1, color => 'white', bg_color => '#1B5E20', border => 1,
),
review => $workbook->add_format(
bold => 1, color => '#5F4300', bg_color => '#FFD966', border => 1,
),
unresolved => $workbook->add_format(
bold => 1, color => 'white', bg_color => '#8B0000', border => 1,
),
not_searched => $workbook->add_format(
bold => 1, color => '#404040', bg_color => '#BFBFBF', border => 1,
),
);
$workbook->set_properties(
title => 'Convert-Pheno terminology audit',
subject => 'Terminology resolution and candidate review',
author => 'Convert-Pheno',
comments => 'Colors indicate review priority, not clinical confidence',
);
$summary->write_row( 0, 0, [ 'Field', 'Value' ], $header_format );
$summary->set_column( 0, 0, 31 );
$summary->set_column( 1, 1, 58 );
$summary->freeze_panes(1, 0);
for my $index ( 0 .. $#COLUMNS ) {
$audit->write_string( 0, $index, $COLUMNS[$index], $header_format );
}
$audit->freeze_panes( 1, 3 );
$audit->set_selection( 1, 0 );
$audit->set_zoom(85);
my @widths = (
8, 18, 25, 28, 28, 28, 18, 32, 22, 14,
20, 20, 20, 18, 18, 16, 27, 29, 16, 20,
17, 20, 32, 22, 17, 17,
);
for my $index ( 0 .. $#widths ) {
$audit->set_column( $index, $index, $widths[$index] );
}
# Keep the default workbook focused on review while retaining all evidence
# for users who choose to unhide the technical columns.
my @hidden_columns = ( 6, 9 .. 15, 18 .. 21 );
for my $index (@hidden_columns) {
$audit->set_column( $index, $index, $widths[$index], undef, 1 );
}
$self->{format} = 'xlsx';
$self->{workbook} = $workbook;
$self->{summary_worksheet} = $summary;
$self->{audit_worksheet} = $audit;
$self->{header_format} = $header_format;
$self->{summary_label_format} = $summary_label_format;
$self->{score_format} = $score_format;
$self->{row_format} = \%row_format;
$self->{legend_format} = \%legend_format;
$self->{next_xlsx_row} = 1;
return 1;
}
sub _write_xlsx_row {
my ( $self, $row ) = @_;
die "XLSX terminology audits support at most "
. MAX_XLSX_DATA_ROWS
. " decisions; use .tsv.gz for larger runs\n"
if $self->{next_xlsx_row} > MAX_XLSX_DATA_ROWS;
my $worksheet = $self->{audit_worksheet};
my $xlsx_row = $self->{next_xlsx_row};
for my $index ( 0 .. $#COLUMNS ) {
my $column = $COLUMNS[$index];
my $value = $row->{$column};
next unless defined $value && length $value;
if ( $NUMERIC_COLUMN{$column} && _is_number($value) ) {
my $format =
$column eq 'best_candidate_score' || $column eq 'score_margin'
? $self->{score_format}
: undef;
$worksheet->write_number( $xlsx_row, $index, $value, $format );
}
else {
# Explicit string writes prevent source values beginning with '='
# from being interpreted as spreadsheet formulas.
$worksheet->write_string( $xlsx_row, $index, "$value" );
}
}
$self->{next_xlsx_row}++;
return 1;
}
sub _close_xlsx {
my ($self) = @_;
my $summary = $self->{summary_worksheet};
my $config = $self->{config};
my $counts = $self->{counts};
my @summary_rows = (
[ 'report_format', 'XLSX' ],
[ 'configured_search_mode', $config->{search} ],
[ 'text_similarity_method', $config->{text_similarity_method} ],
[ 'min_text_similarity_score', $config->{min_text_similarity_score} ],
[ 'levenshtein_weight', $config->{levenshtein_weight} ],
[ 'total_decisions', $self->{total_rows} ],
[ 'exact_or_direct', $counts->{resolved} ],
[ 'similarity_review', $counts->{review} ],
[ 'unresolved', $counts->{unresolved} ],
[ 'not_searched_or_source_fallback', $counts->{not_searched} ],
);
for my $index ( 0 .. $#summary_rows ) {
$summary->write_string(
$index + 1,
0,
$summary_rows[$index][0],
$self->{summary_label_format},
);
my $value = $summary_rows[$index][1];
if ( defined $value && _is_number($value) ) {
$summary->write_number( $index + 1, 1, $value );
}
else {
$summary->write_string( $index + 1, 1, defined $value ? "$value" : q{} );
}
}
my $legend_row = @summary_rows + 3;
$summary->write_string( $legend_row, 0, 'Review colors', $self->{summary_label_format} );
my @legend = (
[ resolved => 'Exact, direct, or configured resolution' ],
[ review => 'Similarity or spelling result to review' ],
[ unresolved => 'No term emitted' ],
[ not_searched => 'Not searched or source fallback' ],
);
for my $index ( 0 .. $#legend ) {
my ( $category, $description ) = @{ $legend[$index] };
$summary->write_string(
$legend_row + $index + 1,
0,
$category,
$self->{legend_format}{$category},
);
$summary->write_string( $legend_row + $index + 1, 1, $description );
}
$summary->write_string(
$legend_row + @legend + 2,
0,
'Interpretation',
$self->{summary_label_format},
);
$summary->write_string(
$legend_row + @legend + 2,
1,
'Colors prioritize review; they do not measure clinical confidence.',
);
$summary->write_string(
$legend_row + @legend + 4,
0,
'Workbook view',
$self->{summary_label_format},
);
$summary->write_string(
$legend_row + @legend + 4,
1,
'Technical audit columns are present but hidden by default.',
);
my $audit = $self->{audit_worksheet};
$audit->autofilter( 0, 0, $self->{total_rows}, $#COLUMNS );
if ( $self->{total_rows} ) {
my $last_row = $self->{total_rows} + 1;
my $range = "A2:Z$last_row";
my @rules = (
[ unresolved => '=$P2="not_found"' ],
[ not_searched => '=OR($P2="not_searched",$Q2="source_fallback")' ],
[ review => '=AND($P2<>"not_found",$P2<>"not_searched",$Q2<>"source_fallback",OR($T2="similarity",$V2="one_token_relaxed"))' ],
[ resolved => '=AND($P2<>"not_found",$P2<>"not_searched",$Q2<>"source_fallback",$T2<>"similarity",$V2<>"one_token_relaxed")' ],
);
for my $rule (@rules) {
$audit->conditional_formatting(
$range,
{
type => 'formula',
criteria => $rule->[1],
format => $self->{row_format}{ $rule->[0] },
}
);
}
}
$self->{workbook}->close()
or die "Failed to finalize XLSX terminology audit '$self->{path}'\n";
return 1;
}
sub _review_category {
my ($row) = @_;
return 'unresolved' if ( $row->{match_status} // q{} ) eq 'not_found';
return 'not_searched'
if ( $row->{match_status} // q{} ) eq 'not_searched'
|| ( $row->{decision_reason} // q{} ) eq 'source_fallback';
return 'review'
if ( $row->{lookup_resolution} // q{} ) eq 'similarity'
|| ( $row->{retrieval_path} // q{} ) eq 'one_token_relaxed';
return 'resolved';
}
sub _review_action {
my ($row) = @_;
my $category = _review_category($row);
return 'keep' if $category eq 'resolved';
return 'review_similarity' if $category eq 'review';
return 'resolve_or_accept_fallback' if $category eq 'unresolved';
return 'review_source_fallback';
}
sub _tsv_field {
my ($value) = @_;
return q{} unless defined $value;
$value =~ s/[\t\r\n]+/ /g;
return $value;
}
( run in 1.969 second using v1.01-cache-2.11-cpan-1191d43216d )