1111# - disulfide : both canonical Cys present + bonded
1212# - disulfide_broken : both canonical Cys present + unbonded
1313# - disulfide_missing : a canonical position has no Cys (a phantom hit
14- # is emitted at the expected position with cysClass
15- # set to disulfide_missing)
14+ # is emitted at that role with cysClass set to
15+ # disulfide_missing)
1616# - cys_extra : a Cys at any non-canonical position, regardless
1717# of bonding state
18+ #
19+ # Per-cys drill-down is out of scope per the refreshed spec, so each hit
20+ # only carries the two fields actually consumed downstream: the class label
21+ # (for counts + the developability bump) and the side-chain rSASA (for the
22+ # "exposed extra" count + the buried-vs-exposed gate in the scoring bump).
1823
1924import math
2025from dataclasses import dataclass
2934
3035@dataclass
3136class CysteineHit :
32- chainId : str
33- resSeq : int
34- iCode : str
35- resName : str # always "CYS" for real hits; "-" for phantom missing entries
36- chainRole : str # "H" / "L" / "-" when chain isn't mapped to either
3737 cysClass : str # "disulfide" | "disulfide_broken" | "disulfide_missing" | "cys_extra" | "bonded" | "unbonded"
38- bondingState : str # "bonded" | "unbonded" | "missing"
39- sasa : Optional [float ]
40- rsasa : Optional [float ]
41- sidechainSasa : Optional [float ]
4238 sidechainRsasa : Optional [float ]
43- partnerChainId : str
44- partnerResSeq : Optional [int ]
45- partnerIcode : str
4639
4740
4841def _dist (a , b ) -> float :
@@ -93,41 +86,20 @@ def _scan_disulfides(cys_records) -> dict[int, int]:
9386def _classify_cys (
9487 res_seq : int ,
9588 role : Optional [str ],
96- bonding_state : str ,
89+ bonded : bool ,
9790 canonical_keys : set [tuple [str , int ]],
9891 canonical_positions : dict ,
9992) -> str :
10093 """R23 four-state classification. Falls back to raw bonding state
10194 when numbering isn't wired (no role / no canonical positions for the
102- scheme), so the table is still useful in the auto-detect-only case."""
95+ scheme), so the hit is still classified usefully in the auto-detect-only
96+ case (though the resulting "bonded"/"unbonded" values are inert
97+ downstream)."""
10398 if role is not None and (role , res_seq ) in canonical_keys :
104- return "disulfide" if bonding_state == " bonded" else "disulfide_broken"
99+ return "disulfide" if bonded else "disulfide_broken"
105100 if role is not None and canonical_positions :
106101 return "cys_extra"
107- return bonding_state
108-
109-
110- def _phantom_missing_cys (role : str , pos : int , chain_id_for_role : str ) -> CysteineHit :
111- """Emit a placeholder row for a canonical Cys position the structure
112- doesn't actually contain (resName "-" sentinel). Spec R23: missing
113- canonical Cys is a developability red flag and needs to appear in
114- the cys table even without a real residue to attach to."""
115- return CysteineHit (
116- chainId = chain_id_for_role ,
117- resSeq = pos ,
118- iCode = "-" ,
119- resName = "-" ,
120- chainRole = role ,
121- cysClass = "disulfide_missing" ,
122- bondingState = "missing" ,
123- sasa = None ,
124- rsasa = None ,
125- sidechainSasa = None ,
126- sidechainRsasa = None ,
127- partnerChainId = "-" ,
128- partnerResSeq = None ,
129- partnerIcode = "-" ,
130- )
102+ return "bonded" if bonded else "unbonded"
131103
132104
133105def detect_cysteines (
@@ -144,69 +116,42 @@ def detect_cysteines(
144116 cys_records = _collect_cys_records (parsed )
145117 partner_of = _scan_disulfides (cys_records )
146118
147- # Per-scheme canonical positions + the set we test each real Cys against.
148119 canonical_positions : dict [str , tuple [int , int ]] = (
149120 CANONICAL_CYS_POSITIONS .get (numbering_scheme , {}) if numbering_scheme else {}
150121 )
151122 canonical_keys : set [tuple [str , int ]] = {
152123 (role , pos ) for role , positions in canonical_positions .items () for pos in positions
153124 }
154- # Used below to skip phantom rows for canonical positions that ARE
155- # filled , keyed by (role, res_seq) .
156- cys_by_role_pos : dict [tuple [str , int ], int ] = {}
157- for idx , ( chain_id , r , _ca , _sg ) in enumerate ( cys_records ) :
125+ # Tracks which canonical (role, res_seq) slots are actually filled, so
126+ # missing-Cys phantom rows below don't duplicate a real hit .
127+ filled_canonical : set [tuple [str , int ]] = set ()
128+ for chain_id , r , _ca , _sg in cys_records :
158129 role = role_of_chain (chain_id , heavy_chain_id , light_chain_id )
159130 if role is not None :
160- cys_by_role_pos [( role , r .res_seq )] = idx
131+ filled_canonical . add (( role , r .res_seq ))
161132
162133 hits : list [CysteineHit ] = []
163134 for idx , (chain_id , r , _ca , _sg ) in enumerate (cys_records ):
164135 key = (chain_id , f"{ r .res_seq } { r .i_code } " .strip ())
165136 sasa_info = sasa_lookup .get (key , {})
166137 role = role_of_chain (chain_id , heavy_chain_id , light_chain_id )
167-
168- partner_idx = partner_of .get (idx )
169- if partner_idx is not None :
170- p_chain , p_res , _ , _ = cys_records [partner_idx ]
171- bonding_state = "bonded"
172- partner_chain = p_chain
173- partner_res_seq : Optional [int ] = p_res .res_seq
174- partner_icode = p_res .i_code or "-"
175- else :
176- bonding_state = "unbonded"
177- partner_chain = "-"
178- partner_res_seq = None
179- partner_icode = "-"
180-
138+ bonded = partner_of .get (idx ) is not None
181139 hits .append (
182140 CysteineHit (
183- chainId = chain_id ,
184- resSeq = r .res_seq ,
185- iCode = r .i_code or "-" ,
186- resName = "CYS" ,
187- chainRole = role or "-" ,
188141 cysClass = _classify_cys (
189- r .res_seq , role , bonding_state , canonical_keys , canonical_positions
142+ r .res_seq , role , bonded , canonical_keys , canonical_positions
190143 ),
191- bondingState = bonding_state ,
192- sasa = sasa_info .get ("sasa" ),
193- rsasa = sasa_info .get ("rsasa" ),
194- sidechainSasa = sasa_info .get ("sideChainSasa" ),
195144 sidechainRsasa = sasa_info .get ("sideChainRsasa" ),
196- partnerChainId = partner_chain ,
197- partnerResSeq = partner_res_seq ,
198- partnerIcode = partner_icode ,
199145 )
200146 )
201147
202148 # Phantom rows for missing canonical Cys (R23).
203149 for role , (p1 , p2 ) in canonical_positions .items ():
204- chain_id_for_role = heavy_chain_id if role == "H" else light_chain_id
205- if not chain_id_for_role :
150+ if not (heavy_chain_id if role == "H" else light_chain_id ):
206151 continue
207152 for pos in (p1 , p2 ):
208- if (role , pos ) in cys_by_role_pos :
153+ if (role , pos ) in filled_canonical :
209154 continue
210- hits .append (_phantom_missing_cys ( role , pos , chain_id_for_role ))
155+ hits .append (CysteineHit ( cysClass = "disulfide_missing" , sidechainRsasa = None ))
211156
212157 return hits
0 commit comments