From 7f840ce1796bc8286ed888bb6351e35fef646467 Mon Sep 17 00:00:00 2001 From: Joyce Yan <5653616+joyceyan@users.noreply.github.com> Date: Wed, 27 Nov 2024 10:55:01 -0800 Subject: [PATCH] feat: add genetic ancestry fields for schema 5.3 (#1132) --- .../schema_definitions/schema_definition.yaml | 12 ++ .../cellxgene_schema/validate.py | 107 ++++++++++++++++ .../tests/fixtures/examples_validate.py | 90 +++++++++++++ .../tests/fixtures/h5ads/example_valid.h5ad | Bin 575888 -> 593864 bytes .../tests/test_schema_compliance.py | 118 ++++++++++++++++++ 5 files changed, 327 insertions(+) diff --git a/cellxgene_schema_cli/cellxgene_schema/schema_definitions/schema_definition.yaml b/cellxgene_schema_cli/cellxgene_schema/schema_definitions/schema_definition.yaml index 28a3fad54..d14a0442e 100644 --- a/cellxgene_schema_cli/cellxgene_schema/schema_definitions/schema_definition.yaml +++ b/cellxgene_schema_cli/cellxgene_schema/schema_definitions/schema_definition.yaml @@ -582,3 +582,15 @@ components: - "cell culture" - "organoid" - "tissue" + genetic_ancestry_African: + type: genetic_ancestry_value + genetic_ancestry_East_Asian: + type: genetic_ancestry_value + genetic_ancestry_European: + type: genetic_ancestry_value + genetic_ancestry_Indigenous_American: + type: genetic_ancestry_value + genetic_ancestry_Oceanian: + type: genetic_ancestry_value + genetic_ancestry_South_Asian: + type: genetic_ancestry_value diff --git a/cellxgene_schema_cli/cellxgene_schema/validate.py b/cellxgene_schema_cli/cellxgene_schema/validate.py index 024a51ec0..01a2d1401 100644 --- a/cellxgene_schema_cli/cellxgene_schema/validate.py +++ b/cellxgene_schema_cli/cellxgene_schema/validate.py @@ -416,6 +416,109 @@ def _count_matrix_nonzero(self, matrix_name: str, matrix: Union[np.ndarray, spar self.number_non_zero[matrix_name] = nnz return nnz + def _validate_genetic_ancestry(self): + """ + Performs row-based validation of the genetic_ancestry_X fields. This ensures that a valid row must be: + - all float('nan') if organism is not homo sapiens or info is unavailable + - sum to 1.0 + + Additionally, verifies that all rows with the same donor_id must have the same genetic ancestry values + """ + ancestry_columns = [ + "genetic_ancestry_African", + "genetic_ancestry_East_Asian", + "genetic_ancestry_European", + "genetic_ancestry_Indigenous_American", + "genetic_ancestry_Oceanian", + "genetic_ancestry_South_Asian", + ] + + organism_column = "organism_ontology_term_id" + donor_id_column = "donor_id" + + # Skip any additional validation if the genetic ancestry or organism columns are not present + # An error for missing columns will be raised at a different point + required_columns = ancestry_columns + [organism_column, donor_id_column] + for column in required_columns: + if column not in self.adata.obs.columns: + return + + donor_id_to_ancestry_values = dict() + + def is_valid_row(row): + ancestry_values = row[ancestry_columns] + + # If ancestry values are different for the same donor id, then this row is invalid + donor_id = row[donor_id_column] + if donor_id in donor_id_to_ancestry_values: + if not donor_id_to_ancestry_values[donor_id].equals(ancestry_values): + return False + else: + donor_id_to_ancestry_values[donor_id] = ancestry_values + + # All values are NaN. This is always valid, regardless of organism + if ancestry_values.isna().all(): + return True + + # If any values are NaN, and we didn't return in the earlier all NaN check, then + # this is invalid + if ancestry_values.isna().any(): + return False + + # If organism is not homo sapiens, and we didn't return in the earlier all NaN check, + # then this row is invalid + if row[organism_column] != "NCBITaxon:9606": + return False + + # The sum of genetic ancestry values should be approximately 1.0 + if ( + ancestry_values.apply(lambda x: isinstance(x, (float, int))).all() + and abs(ancestry_values.sum() - 1.0) <= 1e-6 + ): + return True + + return False + + invalid_rows = ~self.adata.obs.apply(is_valid_row, axis=1) + + if invalid_rows.any(): + invalid_indices = self.adata.obs.index[invalid_rows].tolist() + self.errors.append( + f"obs rows with indices {invalid_indices} have invalid genetic_ancestry_* values. All " + f"observations with the same donor_id must contain the same genetic_ancestry_* values. If " + f"organism_ontolology_term_id is NOT 'NCBITaxon:9606' for Homo sapiens, then all genetic" + f"ancestry values MUST be float('nan'). If organism_ontolology_term_id is 'NCBITaxon:9606' " + f"for Homo sapiens, then the value MUST be a float('nan') if unavailable; otherwise, the " + f"sum of all genetic_ancestry_* fields must be equal to 1.0" + ) + + def _validate_individual_genetic_ancestry_value(self, column: pd.Series, column_name: str): + """ + The following fields are valid for genetic_ancestry_value columns: + - float values between 0 and 1 + - float('nan') + """ + if column.dtype != float: + self.errors.append(f"Column '{column_name}' in obs must be float, not '{column.dtype.name}'.") + return + + def is_individual_value_valid(value): + if isinstance(value, (float, int)) and 0 <= value <= 1: + return True + # Ensures only float('nan') or numpy.nan is valid, None is invalid + if isinstance(value, float) and pd.isna(value): + return True + return False + + # Identify invalid values + invalid_values = column[~column.map(is_individual_value_valid)] + + if not invalid_values.empty: + self.errors.append( + f"Column '{column_name}' in obs contains invalid values: {invalid_values.to_list()}. " + f"Valid values are floats between 0 and 1 or float('nan')." + ) + def _validate_column_feature_is_filtered(self, column: pd.Series, column_name: str, df_name: str): """ Validates the "is_feature_filtered" in adata.var. This column must be bool, and for genes that are set to @@ -505,6 +608,9 @@ def _validate_column(self, column: pd.Series, column_name: str, df_name: str, co if column_def.get("type") == "feature_is_filtered": self._validate_column_feature_is_filtered(column, column_name, df_name) + if column_def.get("type") == "genetic_ancestry_value": + self._validate_individual_genetic_ancestry_value(column, column_name) + if "enum" in column_def: bad_enums = [v for v in column.drop_duplicates() if v not in column_def["enum"]] if bad_enums: @@ -781,6 +887,7 @@ def _validate_dataframe(self, df_name: str): f"Column '{column_name}' in dataframe '{df_name}' contains a category '{category}' with " f"zero observations. These categories will be removed when `--add-labels` flag is present." ) + self._validate_genetic_ancestry() categorical_types = {type(x) for x in column.dtype.categories.values} # Check for columns that have illegal categories, which are not supported by anndata 0.8.0 # TODO: check if this can be removed after upgading to anndata 0.10.0 diff --git a/cellxgene_schema_cli/tests/fixtures/examples_validate.py b/cellxgene_schema_cli/tests/fixtures/examples_validate.py index 470c165ce..accbecfcd 100644 --- a/cellxgene_schema_cli/tests/fixtures/examples_validate.py +++ b/cellxgene_schema_cli/tests/fixtures/examples_validate.py @@ -48,6 +48,12 @@ "HsapDv:0000003", "donor_1", "nucleus", + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], [ "CL:0000192", @@ -62,6 +68,12 @@ "MmusDv:0000003", "donor_2", "na", + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], ], index=["X", "Y"], @@ -78,6 +90,12 @@ "development_stage_ontology_term_id", "donor_id", "suspension_type", + "genetic_ancestry_African", + "genetic_ancestry_East_Asian", + "genetic_ancestry_European", + "genetic_ancestry_Indigenous_American", + "genetic_ancestry_Oceanian", + "genetic_ancestry_South_Asian", ], ) @@ -144,6 +162,12 @@ "donor_1", "na", 0, + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], [ 2, @@ -161,6 +185,12 @@ "donor_2", "na", 1, + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], ], index=["X", "Y"], @@ -180,6 +210,12 @@ "donor_id", "suspension_type", "in_tissue", + "genetic_ancestry_African", + "genetic_ancestry_East_Asian", + "genetic_ancestry_European", + "genetic_ancestry_Indigenous_American", + "genetic_ancestry_Oceanian", + "genetic_ancestry_South_Asian", ], ) @@ -203,6 +239,12 @@ "HsapDv:0000003", "donor_1", "na", + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], [ "CL:0000192", @@ -217,6 +259,12 @@ "MmusDv:0000003", "donor_2", "na", + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], ], index=["X", "Y"], @@ -233,6 +281,12 @@ "development_stage_ontology_term_id", "donor_id", "suspension_type", + "genetic_ancestry_African", + "genetic_ancestry_East_Asian", + "genetic_ancestry_European", + "genetic_ancestry_Indigenous_American", + "genetic_ancestry_Oceanian", + "genetic_ancestry_South_Asian", ], ) @@ -255,6 +309,12 @@ "HsapDv:0000003", "donor_1", "na", + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], [ "CL:0000192", @@ -269,6 +329,12 @@ "MmusDv:0000003", "donor_2", "na", + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], ], index=["X", "Y"], @@ -285,6 +351,12 @@ "development_stage_ontology_term_id", "donor_id", "suspension_type", + "genetic_ancestry_African", + "genetic_ancestry_East_Asian", + "genetic_ancestry_European", + "genetic_ancestry_Indigenous_American", + "genetic_ancestry_Oceanian", + "genetic_ancestry_South_Asian", ], ) @@ -493,6 +565,12 @@ "tissue:1", "sre:1", "development_stage:1", + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], [ "cell_type:1", @@ -503,6 +581,12 @@ "tissue:1", "sre:1", "development_stage:1", + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), + float("nan"), ], ], index=["X", "Y"], @@ -515,6 +599,12 @@ "tissue_ontology_term_id", "self_reported_ethnicity_ontology_term_id", "development_stage_ontology_term_id", + "genetic_ancestry_African", + "genetic_ancestry_East_Asian", + "genetic_ancestry_European", + "genetic_ancestry_Indigenous_American", + "genetic_ancestry_Oceanian", + "genetic_ancestry_South_Asian", ], ) diff --git a/cellxgene_schema_cli/tests/fixtures/h5ads/example_valid.h5ad b/cellxgene_schema_cli/tests/fixtures/h5ads/example_valid.h5ad index ec5f0aee29d9a739fe0048b38b37689e2ed5f6b0..a1b121bdf605779d0a34649907a8467a31fa1076 100644 GIT binary patch delta 41271 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 delta 26319 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 z zYiPS04xYC<`F#VJqHJhxd&q>cw{Smd=2zCvX-#)>JiXy5r^fW0IdP7_d5DKO^v5@} zy{I0D=s3>AR6PR|{zhdP$bAaF<{9|gPaQLG77ySz_5faDW&P~n0Dglr@a%W&r;IPe z0M5pO^%Si5)*irb;|K7Pa{!Cj_afDr-Z}rDTv>|ny>sIqCptckHb(mmFu!+9%K7n= za)D}7#gE*k(>6oYZAyT|A8}GPy1nquyErNTDI5Zwn&>vAxx8@qoz^w(Iv3t{8Nd#q zJ(6mA9m|~(eykdg^J5itON-*$LB}k*9qr$9)6YYj+Z$W*TpQnxC2-_>e24hZ(C@9W zK0i+v-466?mUmrkNsx=uF4(aY#0m|A&R*$r_OJ1J(RuM1$LHI6jkamJyr`-F$9k=* zG{d;KTjBABR*b$EZK_*jCscG|yR}}ujhig&(rr-nBkoJ>@Oj(Z2mi$*5q+Ys!>aU1 z1fkjQosH{dv$J=&`=rm?+;$m~W9MsNsd@|IHraPwyrt1mSf=_kx*%GPW$ICISfW@rhH6w%3q^b3 zRE4-7WwY@*4ATjv#%nN4S3@*ji(xv^)VLDEbQMJ78!${2YrGo6xcu!LjkposeZ(^{ z+Slvh$YJjq_s*Wk0RG}x*hhOlvQzhsZ;Tfn3gEqtS4zE|-M$CuxynjG1^SeY;dbh| zveU+m=^THY)r`6o>T#@l%;R&PFIVBRMP8rVw-mz<)Y9To*G+wLxdT(5I5G8s6B8YF zV(K#|rg3*->N6+CKI7{%+5qY_r$U*)Q9#h74nqz?%iG5qOKh zw+Osd;B8S{%WvxK(Fn$57PVNns7~M=0^cg|Z35peFukx;`L*`z=O;SC#&-(5Q{Y_! z-(_R%Sm>?b95R={ZX4I?44s66M_}p~XMyyRablmqeu3#dC>r z#?#%Km5W=?h6}28w>XB%#)ejfLLJ5dCk(iDG)TgUXA?03J@_c67k(3LW2}xQzFhgE<-DkkJhi|AcXuL&u9)a48fXxC1|uBKmpq(DqRriFroDj@MCn3>-RyVk~t}z)YtNcRCxw zEqgRMFDuu`piO@|AW~6TA?&Cu9+K>=te}x?Z~o^7bAv_>9C`$^=dx)TXqyqDUJBs7 zUF?>Cna3%a;f%0x1(k?a&S;p0ND?)f6Hx!0Rz}FUh6~Ee$_pE_>|9}- zqO;lloUCB3QAFwiyjP`;N`hJ0K{~g>GnxRNTdAJS4(Ei7dEER^I6Kdn&qZa2GP8{Z zJclyqBxE7$hl9b8QDXOJE>4nCE<2o)8Oo)jtVhwj?98CC7>e(}YOduX@`5>GV~K5X zC_CG@j>4#k55rQsv>ZHoDdSplbF%V`az2vDp+;TLR%L~-68!Q5&8uAOq2;U|!UIcV zg>6W7F6yl0gq#pgk5zV=!His^f}fepOpLGO@Vys2 zU-2Y?;_3R8upX8?irX4NFWmAdZfj)FTNhs#e0c6Yo{s!5-CE%;%l5*kC#-Ey-{!f> zop9cQ37qpj$p0U&?_#D>o5ns zIyBZRHjVWXPh-8p(^xMdHP%Z=jqN35ZL|WthSUl~)SU*b7g#TXwZ2{iYpfT+8tXN% z#=Hi`X6XpM2-dhvR9G(X^#U&wc)7qU1YSv)x>Z-WN+?taTq*Dk0&8}!%hTHj8td%? zjcL0aoVT!<43p`ceLV>3VJYC=! zdVfM2pqZG)nu%$wnV812MS*(%LZ|Ef3yt;ug~oH`V>M3{IA7oe0xuM}MBq|^7YSUu zSVUYa@DhQq6L_hOwFlDAxyG6*YOEh~8`m1zk!S_&NHo?xq;Z`vz!caeux4Vq%QX>W zjHP=-2F=8D26`tr9g${YI$bj{jWrX~*k)qU$6OOJl}@eGOiX9cOiW{{+-ZPjVme(j zF^x47)0ml9%+F{dM(I?)+Lv`!pqZF1Kr=CoH51cVGck=d6Vq5RF|0>pO~eqZ`qkHQ zoCXXOEe;5rB5i7@&JZ|L;4Fc&Z5)07=Lm&dfkOg^1E4XZ9e`FPRGm!SN73957K z5>yvj`Vv7^m!NEd%9o(JQ5{53`IeTt1eL%mVs!~BQC))a(9*v+&e~|`!IP<;zg<=&&e_J+?UC7KPJyeP>DPbVDdbW8^Yu{z~nh4MxIl7{jbRL zAe;9Rc^=H&`ta}JYdo1rT593tz8p(^s6$K?5nD0v#8HYoPv#*}rOrB>_w?85 zSLC_U?oT4mH*ncZo>#}n^NmcN*KiSvJg?=Z=Mi~cM`0$^|3Ohulz^3XAP zwl8xk^1PkxSLC^t3}W(J$8#V=F(jSE2sNY z?ksk?53tkS8*{q&l_YYy``GF3XJvM}4^kZg^C8J*k}T&tp#aVRpI)qE7c9 z^>P5eo1~oXBQdA@C_CN9xCfNeeLUuLpJ1o^Bs<+kYDU03%ue?ywvCAuINDX04fcDnDg)BU6USksQ>2UH?E-ILTU%ITh>;_MyEKiO80)BQ6$-48jx za=IT;rv&f|P0Hzh!hNZn?rH8WcDiR6u+#ku52JFrpW3Y;r~6lSx_{&1mDByZtx8V! zGj_UX9ZvUL%;|p41u3Wd4?7n*-7naF<#hkaPWNB-%v4VIOUf58&$F7^vAn>|S5EgU zE=oDwfAbttPWNlpS5Egoc7Kx7{V$izPWPLb)4j+}_YxPOobI=_#pHCqqcA(&@9oma z>HfgAD5v{>{3t7@`=hd|6FFVD?+(nEhfU3eS6}zc_v=;iZFHq>g}YJjuj%;PajmF# zul6N)VRf=K0n|R8zNglYZ!2jSfFoz{c45;>-Ujmr*YPsH8>?w}IqYuqu5br0@P1xY zEg@bl(cV<6<E02sD_x#ugT_|)2{Ck1l6Zj7Tzc26~1^z(pyqy#Zrv(0!z<(C_ zLxDdM_+x=Tq4B3#tLMOJfzJs17lA(&_^$&0P2j%^T>F`bI4kfufj<}c9|HeG;5!B0 z8INm?UGWjdT>{@N@I3-g# zd3!2^fn*#q@;I{-m&N#O7cEY|D(!)~R zSM9wed&7$Ry6v?n9UOPEmv+Q`JxpB`_qBMLr?@X}#?elZ;=Vp!K{NOD^Ez8`-_E>@ zRNS`fZMLPZwfD-nfs%zfuXxi8)a(YJNxMY->M=DrJ<`z~beTf*G8l%Ed8eHStJ#XBE#fk$!QYk5c% z_r;4J>Z`hn`(DS~*S__kxNjMA-*V=@xZxJvaKyVFn)@!NZc*IV-f>g6b?l2CnZ$ip zG54+D{EGWlQl|v)Th5C6uI9c}-1kQAF6O>#7%=x$S37V+k8bOzoi^-!;=WbPeXF^6 z#eLWFh=+*#-o)JZW(W7(5aYfZxjPj1-DKw??rZO_Dehat+;@vTGZpv6+aJVzw{p)a z?u%DIi25t;i(iqZDW|wEeovZQk>bAi1xXx=B=hwLuF6UMO&hz@-8&(v(LRxL7D$EASG5uM>Ewz-0oL3w*uo;$=c%xxgy~UMcV@ zfhz>A6u9;V5wTj}8wFk?@LGY_30x&`bv&*$*2hN}Hwk>Rz#9bKDDWnM`8khGV*Qx& zkYoSr=UmSL?MO7%jznYacr@0INn`D}H0~=5>gU8X{{5X17#<+-K!F1SrwE)X@F0N) zJ22I+egM@Q(j%u=S_LM3Za&bv?=fB<&xTz z@5~%SZOSY1WuJuNr3ihE_%c>kY_J=zzL*VO&I#%yG{G*D*r0uXU$MdNT)tw1cts-0 z27B_FlG$J{UU@1u*qiNFY_Jcl-J#eQ1otCP@VjwUwPm)T%H=6`r;f@t6XM##|! z1sJ6$1gDSUk5;MQ`WdX^A^5BZuM*R*CE+hQk^V5wHe8`(@-OtE^#gsDS1zNsf-z!G zxvx($c^CS~)KMyxTnl|<>KK(uj)gulHC?5WTcM9k%}}Z2ROmyC7W%ARw|-4IzAm#M zm40wPn_H5@Kfj;LKgJ*8t_pLz$V<>imr$~F!3O3AlVQgYNy>LN;N0t`O@2hVu7xbwf_V;fh~ zoiMi1yC9&eqgRr?GF{S*ee7mBHrnl-M#{4edlwAX%JhCR@eZJgl3uR#(Y7WX@Twmt zYHj2~66jvI*9EOdygpaO!|?VI?{z@uPy=*n^fJ@A^PlEiRnYLLXDZ}h@OJWRy|jiU z{rrW$WQEp9-e=y+@t617Jl&!5-@Fx_o1Wnm<6HQ!%~KTZ$RUp6TOanq)9+cMVAhxT z<>jU)RVN)h?ZR&!KgHM0v6obNy`kjq-Z}23!#?<^zjtBOh+*nvDplJw91GXo7WXf3 z#2;Xdh`f8jJI>`ge*`*z?Oo|_vYXism(F`)94}GSFoF{Q>HPE{=a2 zMYSSoeK5WkGWZ9Qg0Hj5MKm>kH1{;Q5<9(xQA$}2#+RRZ`oRr8f1&I6S8z{Pe@S#U z(ZWPe*3EBVr*-vDf%fl%7MLh(%s! 1 + (0.0, 0.0, 1.1, 0.0, 0.0, 0.0), + # One value is < 0.0 + (0.0, 0.0, -0.25, 1.0, 0.25, 0.0), + # Sum is > 1.0 + (0.0, 0.1, 1.0, 0.0, 0.0, 0.0), + # Sum is < 1.0 + (0.0, 0.25, 0.25, 0.25, 0.0, 0.0), + # Only all NaN is valid + (float("nan"), 0.0, 0.0, 0.0, 0.0, 0.0), + # Only all NaN is valid + (numpy.nan, 0.0, 0.0, 0.0, 0.0, 0.0), + ], + ) + def test_genetic_ancestry__invalid( + self, + validator_with_adata, + genetic_ancestry_African, + genetic_ancestry_East_Asian, + genetic_ancestry_European, + genetic_ancestry_Indigenous_American, + genetic_ancestry_Oceanian, + genetic_ancestry_South_Asian, + ): + validator = validator_with_adata + # Second organism in adata is not homo sapiens + validator.adata.obs["genetic_ancestry_African"] = [genetic_ancestry_African, float("nan")] + validator.adata.obs["genetic_ancestry_East_Asian"] = [genetic_ancestry_East_Asian, float("nan")] + validator.adata.obs["genetic_ancestry_European"] = [genetic_ancestry_European, float("nan")] + validator.adata.obs["genetic_ancestry_Indigenous_American"] = [ + genetic_ancestry_Indigenous_American, + float("nan"), + ] + validator.adata.obs["genetic_ancestry_Oceanian"] = [genetic_ancestry_Oceanian, float("nan")] + validator.adata.obs["genetic_ancestry_South_Asian"] = [genetic_ancestry_South_Asian, float("nan")] + validator.validate_adata() + assert len(validator.errors) > 0 + + def test_genetic_ancestry_same_donor_id(self, validator_with_adata): + """ + genetic_ancestry_X fields must be the same when the donor id is the same + """ + validator = validator_with_adata + original_donor_id_column = validator.adata.obs["donor_id"].copy() + + # Second row should have identical donor id + genetic ancestry values, so this should pass validation + validator.adata.obs.iloc[1] = validator.adata.obs.iloc[0].values + validator.validate_adata() + assert validator.errors == [] + + # Update the genetic ancestry values to be different. This should now fail validation + validator.adata.obs["genetic_ancestry_African"] = [1.0, 0.0] + validator.adata.obs["genetic_ancestry_East_Asian"] = [0.0, 1.0] + validator.adata.obs["genetic_ancestry_European"] = [0.0, 0.0] + validator.adata.obs["genetic_ancestry_Indigenous_American"] = [0.0, 0.0] + validator.adata.obs["genetic_ancestry_Oceanian"] = [0.0, 0.0] + validator.adata.obs["genetic_ancestry_South_Asian"] = [0.0, 0.0] + validator.validate_adata() + assert len(validator.errors) > 0 + + # Change the donor id back to two different donor id's. Now, this should pass validation + validator.adata.obs["donor_id"] = original_donor_id_column + validator.validate_adata() + assert validator.errors == [] + class TestVar: """