|
| 1 | +# Polyprotic Acid/Base Support — Change Log |
| 2 | + |
| 3 | +## Stage 1: Storage Layer |
| 4 | +**Status:** No changes needed. |
| 5 | + |
| 6 | +`define_particle_states` already accepts arbitrary lists of `{"name", "z"}` dicts. |
| 7 | +`Reaction` and `ReactionParticipant` are fully generic. The database can hold N states and N-1 reactions as-is. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Stage 2: `define_polyprotic_particle_states` |
| 12 | +**Status:** Done. |
| 13 | +**File:** `pyMBE/pyMBE.py` (inserted after `define_monoprototic_particle_states`) |
| 14 | + |
| 15 | +New method generates N+1 states with auto-naming: |
| 16 | +- Acidic n=3, particle "A": `H3A` (z=0), `H2A` (z=-1), `HA` (z=-2), `A` (z=-3) |
| 17 | +- Basic n=2, particle "B": `H2B` (z=+2), `HB` (z=+1), `B` (z=0) |
| 18 | + |
| 19 | +Returns list of state names for use in reaction definition. |
| 20 | + |
| 21 | +```diff |
| 22 | ++ def define_polyprotic_particle_states(self, particle_name, n, acidity): |
| 23 | ++ ... |
| 24 | ++ states = [] |
| 25 | ++ for k in range(n, -1, -1): |
| 26 | ++ if k == 0: |
| 27 | ++ name = particle_name |
| 28 | ++ elif k == 1: |
| 29 | ++ name = f"H{particle_name}" |
| 30 | ++ else: |
| 31 | ++ name = f"H{k}{particle_name}" |
| 32 | ++ if acidity == "acidic": |
| 33 | ++ z = -(n - k) |
| 34 | ++ else: |
| 35 | ++ z = k |
| 36 | ++ states.append({"name": name, "z": z}) |
| 37 | ++ self.define_particle_states(particle_name=particle_name, states=states) |
| 38 | ++ return [s["name"] for s in states] |
| 39 | +``` |
| 40 | + |
| 41 | +--- |
| 42 | + |
| 43 | +## Stage 3: `define_polyprotic_acidbase_reactions` |
| 44 | +**Status:** Done. |
| 45 | +**File:** `pyMBE/pyMBE.py` (inserted after `define_monoprototic_acidbase_reaction`) |
| 46 | + |
| 47 | +Creates N stepwise reactions from N+1 ordered states. Each reaction pairs adjacent states |
| 48 | +(e.g. `H3A → H2A`, `H2A → HA`, `HA → A`) with its corresponding pKa. Uses |
| 49 | +`reaction_type="polyprotic_acid"` or `"polyprotic_base"`. |
| 50 | + |
| 51 | +Takes `state_names` (from Stage 2) and `pka_list` as input, validates |
| 52 | +`len(pka_list) == len(state_names) - 1`. |
| 53 | + |
| 54 | +```diff |
| 55 | ++ def define_polyprotic_acidbase_reactions(self, particle_name, state_names, pka_list, acidity, metadata=None): |
| 56 | ++ ... |
| 57 | ++ for i, pka in enumerate(pka_list): |
| 58 | ++ reactant_state = state_names[i] |
| 59 | ++ product_state = state_names[i + 1] |
| 60 | ++ reaction = Reaction(participants=[ |
| 61 | ++ ReactionParticipant(..., state_name=reactant_state, coefficient=-1), |
| 62 | ++ ReactionParticipant(..., state_name=product_state, coefficient=1)], |
| 63 | ++ reaction_type=reaction_type, pK=pka, metadata=metadata) |
| 64 | ++ self.db._register_reaction(reaction) |
| 65 | +``` |
| 66 | + |
| 67 | +--- |
| 68 | + |
| 69 | +## Stage 4: New `define_polyprotic_particle` |
| 70 | +**Status:** Done. |
| 71 | +**File:** `pyMBE/pyMBE.py` (new method, inserted after `define_particle`) |
| 72 | + |
| 73 | +Separate function with explicit `n` and `pka_list` parameters. |
| 74 | +`define_particle` is **untouched** — monoprotic behavior fully preserved. |
| 75 | + |
| 76 | +```diff |
| 77 | ++ def define_polyprotic_particle(self, name, sigma, epsilon, n, acidity, pka_list, cutoff=pd.NA, offset=pd.NA): |
| 78 | ++ if len(pka_list) != n: |
| 79 | ++ raise ValueError(...) |
| 80 | ++ state_names = self.define_polyprotic_particle_states(particle_name=name, n=n, acidity=acidity) |
| 81 | ++ initial_state = state_names[0] |
| 82 | ++ self.define_polyprotic_acidbase_reactions(particle_name=name, state_names=state_names, |
| 83 | ++ pka_list=pka_list, acidity=acidity) |
| 84 | ++ tpl = ParticleTemplate(name=name, sigma=..., epsilon=..., cutoff=..., offset=..., |
| 85 | ++ initial_state=initial_state) |
| 86 | ++ self.db._register_template(tpl) |
| 87 | +``` |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +## Stage 5: Extend `setup_cpH` filter |
| 92 | +**Status:** Done. |
| 93 | +**File:** `pyMBE/pyMBE.py` line ~2701 |
| 94 | + |
| 95 | +```diff |
| 96 | ++ acidbase_reaction_types = ["monoprotic_acid", "monoprotic_base", |
| 97 | ++ "polyprotic_acid", "polyprotic_base"] |
| 98 | + for reaction in self.db.get_reactions(): |
| 99 | +- if reaction.reaction_type not in ["monoprotic_acid", "monoprotic_base"]: |
| 100 | ++ if reaction.reaction_type not in acidbase_reaction_types: |
| 101 | +``` |
| 102 | + |
| 103 | +--- |
| 104 | + |
| 105 | +## Stage 6: Extend `setup_grxmc_reactions` and `setup_grxmc_unified` filters |
| 106 | +**Status:** Done. |
| 107 | +**File:** `pyMBE/pyMBE.py` lines ~3017, ~3208 |
| 108 | + |
| 109 | +Same change as Stage 5 applied to both methods. |
| 110 | + |
| 111 | +--- |
| 112 | + |
| 113 | +## Stage 7: Rework `get_pka_set` |
| 114 | +**Status:** Done. |
| 115 | +**File:** `pyMBE/pyMBE.py` (modified `get_pka_set`) |
| 116 | + |
| 117 | +- Now returns `"pka_values": [list]` for polyprotic (vs `"pka_value": float` for monoprotic) |
| 118 | +- Polyprotic particles accumulate pKa values across multiple reactions |
| 119 | +- Monoprotic still raises on duplicates |
| 120 | + |
| 121 | +```diff |
| 122 | ++ if reaction.reaction_type in ["monoprotic_acid", "monoprotic_base"]: |
| 123 | ++ # single pka_value (unchanged behavior) |
| 124 | ++ else: |
| 125 | ++ # accumulate into pka_values list |
| 126 | +``` |
| 127 | + |
| 128 | +--- |
| 129 | + |
| 130 | +## Stage 8: Extend `load_pka_set` and `_check_pka_set` |
| 131 | +**Status:** Done. |
| 132 | +**File:** `pyMBE/pyMBE.py` |
| 133 | + |
| 134 | +`_check_pka_set` now accepts either `"pka_value"` (float) or `"pka_values"` (list), rejects both/neither. |
| 135 | + |
| 136 | +`load_pka_set` routes by key: |
| 137 | +- `"pka_values"` present → calls `define_polyprotic_particle_states` + `define_polyprotic_acidbase_reactions` |
| 138 | +- `"pka_value"` present → calls `define_monoprototic_acidbase_reaction` (unchanged) |
| 139 | + |
| 140 | +New JSON format for polyprotic: |
| 141 | +```json |
| 142 | +{"data": {"PO4": {"acidity": "acidic", "pka_values": [2.15, 7.20, 12.35]}}} |
| 143 | +``` |
| 144 | + |
| 145 | +--- |
| 146 | + |
| 147 | +## Tests |
| 148 | +**File:** `testsuite/polyprotic_acidbase_tests.py` (new, 15 tests) |
| 149 | + |
| 150 | +Covers: triprotic acid states/reactions/initial_state, diprotic base, monoprotic backward compat, |
| 151 | +reaction participant pairing, auto-naming convention, get_pka_set (polyprotic/mixed), |
| 152 | +load_pka_set (polyprotic/mixed), _check_pka_set validation, error handling. |
| 153 | + |
| 154 | +All polyprotic tests use `define_polyprotic_particle(n=..., pka_list=[...])`. |
| 155 | +Monoprotic backward compat test uses unchanged `define_particle(pka=float)`. |
| 156 | + |
| 157 | +All 24 tests pass (15 new + 9 existing acidity). |
| 158 | + |
| 159 | +--- |
| 160 | + |
| 161 | +## Tutorial |
| 162 | +**File:** `tutorials/pyMBE_tutorial.ipynb` |
| 163 | + |
| 164 | +Added new section "How to define polyprotic particles" between the polyampholyte exercise |
| 165 | +and the peptides section. Contains: |
| 166 | +- Explanation of polyprotic vs monoprotic |
| 167 | +- Triprotic phosphoric acid example using `define_polyprotic_particle` |
| 168 | +- Side-by-side comparison with monoprotic `define_particle` |
| 169 | +- Auto-naming convention table |
0 commit comments