Skip to content

Commit db4e9a2

Browse files
authored
Merge pull request #2235 from broadinstitute/development
Release 1.93.0
2 parents b1db771 + f841242 commit db4e9a2

34 files changed

Lines changed: 432 additions & 669 deletions

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ gem 'ostruct'
8888
gem 'logger'
8989
gem 'benchmark'
9090
gem 'drb'
91+
gem 'reline'
92+
gem 'irb'
9193

9294
group :development, :test do
9395
# Access an IRB console on exception pages or by using <%= console %> in views

Gemfile.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ GEM
247247
image_processing (1.12.2)
248248
mini_magick (>= 4.9.5, < 5)
249249
ruby-vips (>= 2.0.17, < 3)
250+
io-console (0.8.0)
251+
irb (1.14.3)
252+
rdoc (>= 4.0.0)
253+
reline (>= 0.4.2)
250254
jbuilder (2.11.2)
251255
activesupport (>= 5.0.0)
252256
jquery-fileupload-rails (1.0.0)
@@ -424,6 +428,8 @@ GEM
424428
rdoc (6.6.3.1)
425429
psych (>= 4.0.0)
426430
regexp_parser (2.6.0)
431+
reline (0.6.0)
432+
io-console (~> 0.5)
427433
representable (3.2.0)
428434
declarative (< 0.1.0)
429435
trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -580,6 +586,7 @@ DEPENDENCIES
580586
google-cloud-bigquery
581587
google-cloud-storage
582588
googleauth
589+
irb
583590
jbuilder (~> 2.7)
584591
jquery-datatables-rails!
585592
jquery-fileupload-rails
@@ -610,6 +617,7 @@ DEPENDENCIES
610617
rack-brotli
611618
rack-mini-profiler
612619
rails (= 6.1.7.9)
620+
reline
613621
rest-client
614622
rubocop
615623
rubocop-rails

app/controllers/api/v1/study_files_controller.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,7 @@ def study_file_params
749749
],
750750
expression_file_info_attributes: [
751751
:_id, :_destroy, :library_preparation_protocol, :units, :biosample_input_type, :modality, :is_raw_counts,
752-
raw_counts_associations: []
752+
:raw_location, raw_counts_associations: []
753753
],
754754
heatmap_file_info_attributes: [:id, :_destroy, :custom_scaling, :color_min, :color_max, :legend_label],
755755
cluster_form_info_attributes: [
@@ -758,8 +758,8 @@ def study_file_params
758758
:external_link_description, spatial_cluster_associations: []
759759
],
760760
metadata_form_info_attributes: [:_id, :use_metadata_convention, :description],
761-
extra_expression_form_info_attributes: [:_id, :taxon_id, :description, :y_axis_label],
762-
ann_data_file_info_attributes: [:_id, :reference_file, :data_fragments],
761+
extra_expression_form_info_attributes: [:_id, :taxon_id, :description, :y_axis_label, :raw_location],
762+
ann_data_file_info_attributes: [:_id, :reference_file, :data_fragments, :raw_location],
763763
differential_expression_file_info_attributes: [
764764
:_id, :clustering_association, :annotation_name, :annotation_scope, :computational_method,
765765
:gene_header, :group_header, :comparison_group_header,

app/javascript/components/explore/DifferentialExpressionPanel.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,7 @@ export default function DifferentialExpressionPanel({
762762
countsByLabelForDe={countsByLabelForDe}
763763
deObjects={deObjects}
764764
setDeFilePath={setDeFilePath}
765+
isAuthorDe={isAuthorDe}
765766
deGroupB={deGroupB}
766767
setDeGroupB={setDeGroupB}
767768
hasOneVsRestDe={hasOneVsRestDe}
@@ -851,7 +852,7 @@ export default function DifferentialExpressionPanel({
851852
bucketId={bucketId}
852853
deFilePath={deFilePath}
853854
handleClear={handleClear}
854-
isAuthorDe={hasPairwiseDe}
855+
isAuthorDe={isAuthorDe}
855856
sizeMetric={sizeMetric}
856857
significanceMetric={significanceMetric}
857858
deFacets={deFacets}

app/javascript/components/upload/AnnDataExpressionStep.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const DEFAULT_NEW_PROCESSED_FILE = {
1010
is_raw_counts: false,
1111
biosample_input_type: 'Whole cell',
1212
modality: 'Transcriptomic: unbiased',
13+
raw_location: '',
1314
raw_counts_associations: []
1415
},
1516
file_type: 'Expression Matrix'

app/javascript/components/upload/ExpressionFileForm.jsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import ExpandableFileForm from './ExpandableFileForm'
88

99
import { TextFormField } from './form-components'
1010
import { findBundleChildren, validateFile } from './upload-utils'
11+
import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons'
12+
import { OverlayTrigger, Popover } from 'react-bootstrap'
13+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
1114

1215
const REQUIRED_FIELDS = [{ label: 'species', propertyName: 'taxon_id' },
1316
{ label: 'Biosample input type', propertyName: 'expression_file_info.biosample_input_type' },
@@ -19,6 +22,9 @@ const RAW_COUNTS_REQUIRED_FIELDS = REQUIRED_FIELDS.concat([{
1922
const PROCESSED_ASSOCIATION_FIELD = [
2023
{ label: 'Associated raw counts files', propertyName: 'expression_file_info.raw_counts_associations' }
2124
]
25+
const RAW_LOCATION_FIELD = [
26+
{ label: 'Raw count data location', propertyName: 'raw_location' },
27+
]
2228

2329
/** renders a form for editing/uploading an expression file (raw or processed) and any bundle children */
2430
export default function ExpressionFileForm({
@@ -49,6 +55,10 @@ export default function ExpressionFileForm({
4955
if (rawCountsRequired && !isRawCountsFile ) {
5056
requiredFields = requiredFields.concat(PROCESSED_ASSOCIATION_FIELD)
5157
}
58+
const requireLocation = (rawCountsRequired || isRawCountsFile) && isAnnDataExperience
59+
if (requireLocation) {
60+
requiredFields = requiredFields.concat(RAW_LOCATION_FIELD)
61+
}
5262
const validationMessages = validateFile({ file, allFiles, allowedFileExts, requiredFields, isAnnDataExperience })
5363

5464
const associatedRawCounts = !isAnnDataExperience && file.expression_file_info.raw_counts_associations.map(id => ({
@@ -61,9 +71,45 @@ export default function ExpressionFileForm({
6171
setShowRawCountsUnits(rawCountsVal)
6272
}
6373

74+
/** create the tooltip and message for the .obsm key name section */
75+
function rawSlotMessage() {
76+
const rawSlotToolTip = <span>
77+
<OverlayTrigger
78+
trigger={['hover', 'focus']}
79+
rootClose placement="top"
80+
delayHide={1500}
81+
overlay={rawSlotHelpContent()}>
82+
<span> Raw count data location * <FontAwesomeIcon icon={faQuestionCircle}/></span>
83+
</OverlayTrigger>
84+
</span>
85+
86+
return <span >
87+
{rawSlotToolTip}
88+
</span>
89+
}
90+
91+
/** gets the popup message to describe .obsm keys */
92+
function rawSlotHelpContent() {
93+
const layersLink = <a href="https://anndata.readthedocs.io/en/latest/generated/anndata.AnnData.layers.html"
94+
target="_blank">layers</a>
95+
const rawLink = <a href="https://anndata.readthedocs.io/en/latest/generated/anndata.AnnData.raw.html"
96+
target="_blank">.raw</a>
97+
return <Popover id="cluster-obsm-key-name-popover" className="tooltip-wide">
98+
<div>
99+
Location of raw count data in your AnnData file. This can be the raw slot ({rawLink}) or the name of a layer in
100+
the {layersLink} section.
101+
</div>
102+
</Popover>
103+
}
104+
64105
return <ExpandableFileForm {...{
65-
file, allFiles, updateFile, saveFile,
66-
allowedFileExts, deleteFile, validationMessages, bucketName, isInitiallyExpanded, isAnnDataExperience
106+
file,
107+
allFiles,
108+
updateFile,
109+
saveFile,
110+
allowedFileExts,
111+
deleteFile,
112+
validationMessages, bucketName, isInitiallyExpanded, isAnnDataExperience
67113
}}>
68114
{!isAnnDataExperience &&
69115
<div className="form-group">
@@ -123,25 +169,32 @@ export default function ExpressionFileForm({
123169
{ isAnnDataExperience &&
124170
<div className="row">
125171
<div className="form-radio col-sm-4">
126-
<label className="labeled-select">I have raw count data in the <strong>adata.raw</strong> slot</label>
172+
<label className="labeled-select">I have raw count data</label>
127173
<label className="sublabel">
128174
<input type="radio"
129175
name={`anndata-raw-counts-${file._id}`}
130176
value="true"
131177
checked={isRawCountsFile}
132-
onChange={e => toggleIsRawCounts(true) } />
178+
onChange={e => toggleIsRawCounts(true)}/>
133179
&nbsp;Yes
134180
</label>
135181
<label className="sublabel">
136182
<input type="radio"
137183
name={`anndata-raw-counts-${file._id}`}
138184
value="false"
139185
checked={!isRawCountsFile}
140-
onChange={e => toggleIsRawCounts(false) }/>
186+
onChange={e => toggleIsRawCounts(false)}/>
141187
&nbsp;No
142188
</label>
143189
</div>
144-
{showRawCountsUnits && <div className="col-sm-8">
190+
{requireLocation && <div className="col-sm-4">
191+
<TextFormField label={rawSlotMessage()}
192+
fieldName="raw_location"
193+
file={file}
194+
updateFile={updateFile}
195+
placeholderText='Specify .raw or name of layer'/></div>
196+
}
197+
{ showRawCountsUnits && <div className="col-sm-4">
145198
<ExpressionFileInfoSelect label="Units *"
146199
propertyName="units"
147200
rawOptions={fileMenuOptions.units}

app/javascript/components/visualization/controls/DifferentialExpressionGroupPicker.jsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ export function PairwiseDifferentialExpressionGroupLists({
320320
export function PairwiseDifferentialExpressionGroupPicker({
321321
bucketId, clusterName, annotation, deGenes, deGroup, setDeGroup,
322322
setDeGenes, countsByLabelForDe, deObjects, setDeFilePath,
323-
deGroupB, setDeGroupB, hasOneVsRestDe, significanceMetric
323+
deGroupB, setDeGroupB, hasOneVsRestDe, significanceMetric, isAuthorDe
324324
}) {
325325
const groups = getLegendSortedLabels(countsByLabelForDe)
326326

@@ -343,7 +343,6 @@ export function PairwiseDifferentialExpressionGroupPicker({
343343

344344
setDeFilePath(deFilePath)
345345

346-
const isAuthorDe = true // SCP doesn't currently automatically compute pairwise DE
347346
const deGenes = await fetchDeGenes(bucketId, deFilePath, isAuthorDe)
348347
setDeGenes(deGenes)
349348
}
@@ -441,7 +440,6 @@ export function OneVsRestDifferentialExpressionGroupPicker({
441440
const deFilePath = basePath + deFileName
442441

443442
setDeFilePath(deFilePath)
444-
445443
const deGenes = await fetchDeGenes(bucketId, deFilePath, isAuthorDe)
446444

447445
setDeGroup(newGroup)

app/javascript/lib/validation/validate-anndata.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,16 @@ async function checkOntologyLabelsAndIds(key, ontologies, groups) {
137137
const rawUniques = Array.from(labelIdPairs)
138138

139139
rawUniques.map(r => {
140-
const [id, label] = r.split(' || ')
140+
let [id, label] = r.split(' || ')
141141
const ontologyShortNameLc = id.split(/[_:]/)[0].toLowerCase()
142142
const ontology = ontologies[ontologyShortNameLc]
143143

144+
if (id.includes(':')) {
145+
// Convert colon to underscore for ontology lookup
146+
const idParts = id.split(':')
147+
id = `${idParts[0]}_${idParts[1]}`
148+
}
149+
144150
if (!(id in ontology)) {
145151
// Register invalid ontology ID
146152
const msg = `Invalid ontology ID: ${id}`

app/lib/differential_expression_service.rb

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def self.run_differential_expression_job(cluster_group, study, user, annotation_
134134
metadata_url = study_file.is_viz_anndata? ?
135135
RequestUtils.data_fragment_url(study_file, 'metadata') :
136136
study.metadata_file.gs_url
137+
cluster_group_id = cluster_group.id
137138
# begin assembling parameters
138139
de_params = {
139140
annotation_name:,
@@ -143,10 +144,12 @@ def self.run_differential_expression_job(cluster_group, study, user, annotation_
143144
group2:,
144145
annotation_file: annotation_scope == 'cluster' ? cluster_url : metadata_url,
145146
cluster_file: cluster_url,
146-
cluster_name: cluster_group.name
147+
cluster_name: set_cluster_name(study, cluster_group, annotation_name, annotation_scope),
148+
cluster_group_id:
147149
}
148150
raw_matrix = ClusterVizService.raw_matrix_for_cluster_cells(study, cluster_group)
149151
de_params[:matrix_file_path] = raw_matrix.gs_url
152+
de_params[:matrix_file_id] = raw_matrix.id
150153
if raw_matrix.file_type == 'MM Coordinate Matrix'
151154
de_params[:matrix_file_type] = 'mtx'
152155
# we know bundle exists and is completed as :raw_matrix_for_cluster_cells will throw an exception if it isn't
@@ -158,6 +161,7 @@ def self.run_differential_expression_job(cluster_group, study, user, annotation_
158161
elsif raw_matrix.file_type == 'AnnData'
159162
de_params[:matrix_file_type] = 'h5ad'
160163
de_params[:file_size] = raw_matrix.upload_file_size
164+
de_params[:raw_location] = raw_matrix.ann_data_file_info.raw_location
161165
else
162166
de_params[:matrix_file_type] = 'dense'
163167
end
@@ -287,6 +291,21 @@ def self.annotation_eligible?(name)
287291
ALLOWED_ANNOTS =~ name && EXCLUDED_ANNOTS !~ name
288292
end
289293

294+
# determine if the requested cluster/annotation has an existing result object
295+
# used for setting cluster name or in pairwise DE when merging in new results
296+
#
297+
# * *params*
298+
# - +study+ (Study) => Study in which results exist
299+
# - +cluster_group+ (ClusterGroup) => Clustering object to source name/file from
300+
# - +annotation_name+ (String) => Name of requested annotation
301+
# - +annotation_scope+ (String) => Scope of requested annotation ('study' or 'cluster')
302+
#
303+
# * *returns*
304+
# - (DifferentialExpressionResult, nil)
305+
def self.find_existing_result(study, cluster_group, annotation_name, annotation_scope)
306+
DifferentialExpressionResult.find_by(study:, cluster_group:, annotation_name:, annotation_scope:)
307+
end
308+
290309
# determine if a study already has DE results for an annotation, taking scope into account
291310
# cluster-based annotations must match to the specified cluster in the annotation object
292311
# for study-wide annotations, return true if any results exist, regardless of cluster as this indicates that DE
@@ -309,6 +328,21 @@ def self.results_exist?(study, annotation)
309328
).exists?
310329
end
311330

331+
# helper to set cluster_name when existing results are present
332+
# this prevents result file URLs breaking because the cluster has been renamed at some point
333+
#
334+
# * *params*
335+
# - +study+ (Study) => Study in which results exist
336+
# - +cluster_group+ (ClusterGroup) => Clustering object to source name/file from
337+
# - +annotation_name+ (String) => Name of requested annotation
338+
# - +annotation_scope+ (String) => Scope of requested annotation ('study' or 'cluster')
339+
#
340+
# * *returns*
341+
# - (String)
342+
def self.set_cluster_name(study, cluster_group, annotation_name, annotation_scope)
343+
find_existing_result(study, cluster_group, annotation_name, annotation_scope)&.cluster_name || cluster_group.name
344+
end
345+
312346
# determine if a study meets the requirements for differential expression:
313347
# 1. public
314348
# 2. has clustering/metadata
@@ -383,12 +417,15 @@ def self.validate_annotation(cluster_group, study, annotation_name, annotation_s
383417
elsif pairwise
384418
missing = [group1, group2] - annotation[:values]
385419
raise ArgumentError, "#{annotation_name} does not contain '#{missing.join(', ')}'" if missing.any?
420+
386421
cell_count = {
387-
"#{group1}" => cells_by_label[group1].count,
388-
"#{group2}" => cells_by_label[group2].count
422+
group1.to_s => cells_by_label[group1]&.count.to_i,
423+
group2.to_s => cells_by_label[group2]&.count.to_i
389424
}.keep_if { |_, c| c < 2 }
390-
raise ArgumentError,
391-
"#{cell_count.keys.join(', ')} does not have enough cells represented in #{identifier}" if cell_count.any?
425+
if cell_count.any?
426+
raise ArgumentError, "#{cell_count.keys.join(', ')} does not have enough cells represented in #{identifier} " \
427+
"for #{cluster_group.name}"
428+
end
392429
end
393430
end
394431

app/lib/file_parse_service.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,15 @@ def self.run_parse_job(study_file, study, user, reparse: false, persist_on_fail:
112112
)
113113
elsif study_file.needs_raw_counts_extraction?
114114
params_object = AnnDataIngestParameters.new(
115-
anndata_file: study_file.gs_url, extract: %w[raw_counts], obsm_keys: nil,
115+
anndata_file: study_file.gs_url, extract: %w[raw_counts],
116+
raw_location: study_file.ann_data_file_info.raw_location, obsm_keys: nil,
116117
file_size: study_file.upload_file_size
117118
)
118119
else
119120
params_object = AnnDataIngestParameters.new(
120121
anndata_file: study_file.gs_url, obsm_keys: study_file.ann_data_file_info.obsm_key_names,
121-
file_size: study_file.upload_file_size, extract_raw_counts: study_file.is_raw_counts_file?
122+
file_size: study_file.upload_file_size, extract_raw_counts: study_file.is_raw_counts_file?,
123+
raw_location: study_file.ann_data_file_info.raw_location
122124
)
123125
end
124126
# TODO extract and parse Raw Exp Data (SCP-4710)

0 commit comments

Comments
 (0)