Revision 3 — recentred on copy-paste method extraction into free functions (self → sip),
plus a separate, optional refactor of the SiP.__init__ input surface.
Baseline measured for this revision: siesta_python/siesta_python.py = 4,376 lines / 137 methods.
Line numbers below are from that baseline and will drift — treat names/context as authoritative.
Rev 2 was a mixin-composition plan. Rev 3 keeps verified facts from it (dependency graph, import mechanics, verification) but changes the method of extraction: instead of moving bodies into
SiP*Mixinclasses, move them into module-level functions that take the object as their first argument. See §1.4 for why, and when the mixin approach is still the better end state.Scope. In scope: structural extraction of
SiPmethod bodies into free functions, and the__init__input refactor. Out of scope: behaviour changes,funcs.py, and the.SiPpickle format (§9). The extraction itself must be behaviour-neutral.
Already done, and reflected throughout this plan:
dQmethod/attribute collision fixed —dQis now a normal method returning the value.Hubbard_electrodecollision fixed — flag is nowself.is_hubbard_electrode(bool), set inHubbard_electrodeand read inread_TSHS; the method name is unchanged.save_fileobject.dirbug fixed — now usesself.dir.- Methods
set,sleep,scream,hearand the module-levelload_calculationremoved. from datetime import datetimeremoved (its only user,scream, is gone).- New helper
ossystem(string)(thin wrapper overos.system). - Most
np.savez→np.savez_compressed; dead comments/whitespace trimmed.
These are behavioural changes to the public surface and belong in their own commits with a before/after check (see §8), not folded into the structural extraction.
External-surface facts to respect (verified by grep over the parent tree, not just this repo):
em.Hubbard_electrode(...)is called fromZandpack/Zandpack/examples/Hubbard/1Hamiltonian.py. → the method name is public API; keep it.- ~15 sites read
*.SiPwith a plainpickle.load(open(..., 'rb'))(Zandpack/Zandpack/wrapper.pyload_object+:1306/:1416,Zandpack/.../AI_fit_base.py, and theexamples/*/…pyscripts that dofrom pickle import load). → do not change the on-disk pickled format without a sniffing loader (§9). Zandpack/Zandpack/documentation*.pyholddrop_names_SiPstring lists naming'scream','set', etc. — harmless strings, no code dependency.
SiP stays exactly where it is — same class, same name, same constructor, same method names.
For each method to be extracted:
- Copy the method body into a module-level function in a domain module.
- Add the object as the first parameter (
sip) and replaceselfwithsip. - Replace the method body in the class with a one-line wrapper that calls the free function.
This is a mechanical, copy-paste transform. It preserves the public API 1:1 (the method still exists with the same signature and behaviour), so no caller anywhere has to change.
- It is copy-paste, not surgery. The only edits inside a moved body are textual:
self→sip. No MRO reasoning, nosuper()chains, no inheritance-ordering puzzles. - The codebase already works this way.
funcs.pyhas 11 functions that take the object asobjectand readobject.lat,object.dir, etc.barebone_fdfalready delegates tobarebones_RUN(self).fdf-family methods already callwrite_fdf(self).ossystemis a fresh example of "helper is a thin shim". Extraction formalises an existing pattern. - Fully incremental and independently verifiable. Each extracted function is a pure move; the diff is readable (one added module function, one shrunk method).
funcs.pyneeds zero changes. It keeps receiving the same object with the same attributes.
class SiPdefinition location and name (siesta_python.siesta_python.SiP) — pickle and imports.- Every method name and signature (wrappers).
- Every attribute name and flat-attribute layout (
sip.lat,sip.elecs, …). - Re-exports of moved module-level names and classes from the facade (pickle + import compat).
| Free functions (this plan) | Mixins (Rev 2) | |
|---|---|---|
| Transform difficulty | copy-paste, self→sip |
move + inheritance wiring |
| API risk | very low (wrappers) | low, but MRO / __init__ ordering matters |
| Testable in isolation | yes — call the function with a stub/new object | yes, via the class |
| Attribute discoverability | lower — attributes are set by free functions | same as today |
| IDE "find usages" of a method | wrapper is trivial; body is a plain function | single class |
| Cross-mixin name collisions | n/a | must be checked by hand |
Pick free functions if the goal is minimum-risk, easy-to-review progress. Mixins remain a reasonable end state if you later want the methods to belong to a class; the wrappers produced here convert to mixins trivially (paste the free function back as a method). You are not locked in.
siesta_python.py shrinks to a facade: class SiP whose methods are one-line wrappers, plus
__init__, plus rotation_matrix, plus re-exports. All logic lives in sip_*.py as plain
functions taking sip. dir(SiP) and every signature are unchanged, so callers and pickles are
unaffected. The win is reviewability and testability of the logic, not a smaller public surface.
# BEFORE (in class SiP)
def name(self, a, b=1):
"""Docstring."""
x = self.lat # attribute read
self.write_more_fdf([a]) # call to another method
return helper(b)# AFTER — new function in sip_<domain>.py
from sip_shared import ld, error, ... # module-level names the body needs
def name(sip, a, b=1):
"""Docstring."""
x = sip.lat # self. -> sip.
sip.write_more_fdf([a]) # still resolves: the wrapper method exists
return helper(b)# AFTER — wrapper in class SiP
def name(self, a, b=1):
return sip_domain.name(self, a, b)- Copy signature verbatim, delete the leading
self, re-addsipas the first parameter. - Replace every
selftoken withsip. Watch for bareselfpassed as a value (write_fdf(self, …),deepcopy(self),self.add_stuff(dic)), not justself.x. - Keep the docstring unchanged.
- If the body references a module-level name (
error,no_rem,rem,ld,wd,PP_path,sp,tb_trans_exec,B,pdos_exec,gulp_path,gen_basis,mix_ps,joblib_backend,M), import it fromsip_shared(§3). - If the body references the class name
SiP, replace with a lazy import (§2.3). - If the body contains
global X, keep it — it now refers to the new module's global (§2.3). - Leave
os.chdir,ossystem,Popen, prints, and numeric code untouched. - In the class, replace the body with
return <module>.<name>(self, …)(dropreturnif the method returnedNone). - Confirm the method signature is byte-identical to before (defaults included).
- Re-run the API snapshot + pyflakes + smoke test (§8).
| Gotcha | Where | Handling |
|---|---|---|
isinstance(x, SiP) |
copy_DM_from, add_atom |
lazy from siesta_python.siesta_python import SiP inside the function, or a duck-type check |
SiP(...) construction |
projection |
same lazy import (circular-import safe: runs at call time) |
global SE_func |
Real_space_SE, Real_space_SI |
keep the block intact; SE_func becomes a global of the new module and del SE_func still deletes it. It is a shared module global, so the parallel_E path is not thread-safe (pre-existing) — verify it still runs after the move (§8.7) |
self.__dict__[...] / initialised_attributes |
__init__ (:333) |
leave in __init__/core; do not move |
parameter literally named object |
copy_DM_from(self, object, …) |
rename to src while moving (removes a builtin shadow) |
| mutable default args | angmom_dic={}, manual_pp=[], Custom=[] |
leave for behaviour fidelity; convert in a separate step if desired |
sys.path.append(__file__[:-16]) |
module head | replace with one path fix in __init__.py / relative imports (§3) |
# sip_readers.py
def dQ(sip):
"""Read excess charge from transiesta calculation."""
dq = None
with open(sip.dir + '/' + 'RUN.out', 'r') as f:
for l in f:
if 'Excess charge' in l:
dq = float(l[37:])
break
return dq# siesta_python.py (class SiP)
def dQ(self):
return sip_readers.dQ(self)That is the entire pattern. Everything else is repetition.
siesta_python/siesta_python/
├── siesta_python.py # facade: class SiP (thin wrappers), rotation_matrix,
│ # re-exports of moved names/classes
├── sip_shared.py # error(), no_rem, rem, ld, wd, PP_path, sp, tb_trans_exec,
│ # B, pdos_exec, gulp_path, gen_basis, mix_ps, joblib_backend, M
├── sip_core.py # directory/lifecycle: clone, pickle, save_file, _init_directory, ...
├── sip_readers.py # read_relax, get_potential_energy, get_fermi_level, dQ, read_tbt, ...
├── sip_runners.py # run_*_in_dir, gin, is_siesta_done, write_analysis_results, ...
├── sip_dftb.py # dftb_* methods + dftb_charge class
├── sip_fdf.py # fdf, fdf_relax, write_more_fdf, *_k_points, Add_Charged_*, ...
├── sip_geometry.py # set_struct, Wrap_unit_cell, Passivate, add_atom, Visualise, ...
├── sip_electrodes.py # find_elec_inds, copy_DM_from, copy_TSHS_from, is_RSSE, ...
├── sip_sisl_io.py # to_sisl, read_TSHS, to_TB_model, write_TB_model, ...
├── sip_transport.py # Real_space_*, projection, calculate_*_RSSE, Hubbard_electrode, ...
├── funcs.py # unchanged
└── dftbplus_interface.py # unchanged
Import mechanics:
sip_shared.pyholds the module-level names above, imported by the domain modules and by the facade (which re-exports them). Dependency graph stays acyclic:sip_shared←sip_*.py← facade.sip_sharednever imports the facade or a domain module.- Replace the filename-length hack
sys.path.append(__file__[:-16])with a single, correct path setup (e.g. insiesta_python/siesta_python/__init__.py, or switch internal imports to relativefrom .funcs import …). Do not copy the[:-16]trick into new modules — it is tied tosiesta_python.pybeing exactly 16 characters and computes the wrong path elsewhere. Caution: relative imports only work when the package is imported as a package (from siesta_python.siesta_python import SiP, as the README does). If anything still runs the file as a script (python siesta_python.py) or relies on the path hack (e.g.basistest.py), keep a single explicitsys.pathinsert instead of relative imports. wd = os.getcwd()still evaluates once at import; it moves tosip_sharedunchanged.if GfModule_extension: from Gf_Module.Gf import read_SE_from_tbtransmoves with its only user (self_energy_from_tbtrans/projection) intosip_transport.py.- The facade re-exports moved names (
error,no_rem, exec defaults) and moved classes (dftb_charge,sisl_replica_for_projection) so old import paths and old pickles keep working.
__init__ currently takes ~70 keyword arguments with a ~270-line body and uses mutable defaults
(kp=[1,1,1], elecs=[], buffer_atoms=[], Device_atoms=[], kp_tbtrans=[1,1,1]).
Keep the flat-kwarg call syntax working (external code and this repo call
SiP(lat, pos, s, basis=…, elecs=…)), but move the storage and defaults into a dataclass, so
there is one source of truth and typo safety:
from dataclasses import dataclass, field, fields
@dataclass
class SiPConfig:
# --- SCF ---
xc: str = 'GGA'
xc_authors: str = 'PBE'
basis: str = 'SZP'
mesh_cutoff: float = 150
# --- runner ---
siesta_exec: str = sp
mpi: str = M
# --- transport ---
trans_emin: float = -1
# ... one field per current kwarg ...
# --- collections: factory, never a bare [] ---
kp: list = field(default_factory=lambda: [1, 1, 1])
elecs: list = field(default_factory=list)
buffer_atoms: list = field(default_factory=list)
Device_atoms: list = field(default_factory=list)
class SiP:
def __init__(self, lat, pos, s, directory_name='noname', **overrides):
unknown = set(overrides) - {f.name for f in fields(SiPConfig)}
if unknown:
raise TypeError(f'SiP: unexpected keyword(s) {sorted(unknown)}')
cfg = SiPConfig(**overrides)
# flat attributes kept for funcs.py and all existing callers
for f in fields(cfg):
setattr(self, f.name, getattr(cfg, f.name))
self.dir = directory_name
...Benefits: one place for defaults, field(default_factory=…) kills the shared-mutable-default
footgun, and unknown keywords raise instead of being silently accepted.
Tip: generate the SiPConfig field list mechanically from the current signature
(inspect.signature(SiP.__init__)), then hand-edit the few that need default_factory or a
derived value. Do not retype ~70 fields by hand.
Cost: the constructor no longer advertises its parameters in help(SiP) / IDE completion. If that
matters more than grouping, keep the explicit signature (§4.4 option B).
Compute these in one place instead of scattering them through the 270-line body. Compute them
from cfg fields (not from self), store them back on cfg, and let the setattr loop copy
them out — that keeps __post_init__ independent of attribute-assignment order:
solution_method(defaults fromelecs).Voltage(fromChem_Pot).NEGF_calc(auto-set whenChem_Potnonzero — note the currentnp.array(Chem_Pot) != 0.0idiom forcesTruewhenChem_Pot is None; fix deliberately, in its own commit).print_no_print(fromprint_console).mpi + ' '.
- Extract the body, signature unchanged. Keep the explicit kwargs; move the block into
_init_directory,_init_core,_init_geometry_attrs(called in the current order). This alone shrinks__init__to ~30 lines with zero API change. - Introduce
SiPConfigand have__init__build it from the explicit kwargs, then set flat attributes. Signature still explicit — no caller change. - Switch the signature to
**overridesand rely onSiPConfigfor defaults + validation. This is the only step that changeshelp(SiP); land it last so it is easy to revert.
Do not skip step 1: it is the safe, no-API-change win and it makes steps 2–3 mechanical.
Keep the explicit signature and only extract the body into _init_* helpers. No config object, no
constructor change. This satisfies "shorter, more readable __init__" without touching the call
surface. The config dataclass is optional value-add, not a requirement.
- Accept the exact same keyword names (no renames).
- Set the exact same flat attributes (
funcs.pyreadsobject.lat,object.spin_pol, …). self.initialised_attributes = list(self.__dict__.keys())must remain present and at the same point in the sequence. Under §4.1 its contents will change (dataclass field order, not the current assignment order) — acceptable, because nothing reads it; just don't delete the line.self.dirand the directory create/reuse logic stay in__init__/core.
Group by where the method's self.* reads/writes cluster, not by name. Move the whole method; do
not split a method across modules.
| Module | Representative methods | Attributes it reads/writes |
|---|---|---|
sip_core.py |
clone, pickle, save_file, _init_directory, _init_core |
dir, wd, sm, sl, initialised_attributes, denchar_files, non_ortho |
sip_readers.py |
read_relax, get_potential_energy, get_fermi_level, relaxed_system_energy, dQ, read_tbt, read_fermi_level_from_out, _has_tbt_nc_files, mulliken |
relaxed_pos, relaxed_lat, Mulliken_C, Mulliken_idx |
sip_runners.py |
run_siesta_in_dir, run_siesta_electrode_in_dir, run_analyze_in_dir, run_tbtrans_in_dir, run_tbt_analyze_in_dir, run_gulp_in_dir, gin, run_fatbands, plot_calculated_bands, is_siesta_done, write_analysis_results |
gulp_results, exec paths |
sip_dftb.py |
dftb_hsd, write_and_run_dftb_in_dir, run_dftb_in_dir, read_dftb_hamiltonian, dftb2sisl, fast_dftb_hk, dftb_find_equiv, dftb_mullikencharge_interface, get_potential_energy_dftb + class dftb_charge |
dic['dftb_*'] |
sip_fdf.py |
fdf, fdf_relax, barebone_fdf, write_more_fdf, delete_fdf, fatband_fdf, custom_bandlines, manual_k_points, make_meshed_k_grid, make_concentrated_k_points, set_parallel_k, get_pseudo_paths, *_mixed_pseudo*, ps_mixer, set_synthetic_mixes, E_field, set_methfessel_paxton, Add_*, add_stuff, write_tb_trans_kp, gen_basis, ideal_cell_charge |
pseudo_paths, mixed_pseudo_list, _mixing_dic, Added_stuff, _manual_* |
sip_geometry.py |
set_struct, reararange, standardise_cell, set_buffer_atoms, set_device_atoms, make_bandpath, Check_Distances, cell_*, center_cell, make_pretty, Wrap_unit_cell, x_to_z, move, move_atom, add_atom, add_buffer, pretty_cell, toASE, read_basis_and_geom, ase_visualise, Visualise, figures, find_polygons, nnr, Find_edges, Passivate*, tile_dm, get_labelled_indices, sisl_sub |
lat, pos, pos_real_space, s, sym*, k_path, UC_idx, buffer_atoms, Device_atoms, TwoDim |
sip_electrodes.py |
add_elecs, find_elec_inds, make_device_coords, copy_DM_from, copy_TSHS_from, reset_minimal_hamiltonian, undo_RSSE, get_RS_pos, is_RSSE, which_type_SE |
elecs, elec_inds, elec_*RSSE, semi_inf, _rearange_indices, RSSEdata |
sip_sisl_io.py |
to_sisl, read_TSHS, read_minimal_TSHS, read_rssi_surface_TSHS, to_TB_model, write_TB_model, fois_gras, manual_H + class sisl_replica_for_projection |
— |
sip_transport.py |
Real_space_SE, Real_space_SI, Real_space_SI_3D, self_energy_from_tbtrans, tbtrans_H_S_btd_pivot, solve_qp_equation, projection, Hubbard_electrode, calculate_hubbard_transport, mimic_tbtrans_spin, calculate_2E_RSSE, calculate_bulksurf_RSSE, from_custom, get_contour_from_failed_RSSE, run_TS_RSSE_calc |
trans_*, Chem_Pot, Voltage, NEGF_calc, save_SE*, kp_tbtrans, contour_settings, RSSE_*, SurRSSE_dict, tbtrans_params_dic, is_hubbard_electrode |
There is no class to inherit from, so "attribute ownership" is a convention: the module whose functions primarily read/write an attribute documents it. Keep the attribute split in the table as the source of truth so two modules don't quietly disagree about a field.
Note: the dQ and Hubbard_electrode collisions are gone (§0), so they are no longer special
cases — dQ moves to sip_readers.py and Hubbard_electrode to sip_transport.py like any other
method.
initialised_attributes(__init__) — written, never read. Keep the assignment at the same point in the sequence; moving it changes the snapshot contents. Don't "clean it up" mid-move.cloneusesdeepcopy(self)— extraction doesn't change this, but verify the roundtrip after each step.- Pickle (
pickle) — the format and the picklable attribute set must not change. Old.SiPfiles exist externally (§0) and are read with plainpickle.load. If you ever add compression, pair it with a magic-byte-sniffing loader and update the ~15 readers (out of scope here). isinstance(x, SiP)/SiP(...)inside moved bodies — resolve with a lazy import; never a module-levelfrom siesta_python.siesta_python import SiPin a domain module (circular import).global SE_funcinReal_space_SE/Real_space_SI— keep the block intact; it becomes a module global ofsip_transport.py.overwrite=Truedefault is destructive (deletes the target directory's contents;Falseblocks oninput()). Pre-existing; flag in a dedicated commit, do not change during the split.- Behaviour changes stay separate. The
dQ/Hubbard_electrodeAPI changes and theset/sleep/scream/hear/load_calculationremovals are already landed — record them as intentional in a commit note; the structural extraction must be behaviour-neutral. funcs.pyis read-only for this effort. If a moved body needs a helper, move the helper too; do not fork logic.
Step 0 is mandatory before anything moves.
| Step | Action | Why |
|---|---|---|
| 0 | API snapshot script (sorted(dir(SiP)) + all signatures) + a smoke test with fixtures; commit |
Without it you cannot tell a move from a change |
| 1 | Create sip_shared.py; fix the sys.path hack; facade re-exports shared names |
Establishes the acyclic import graph and the stop-the-hack fix |
| 2 | sip_core.py (clone, pickle, save_file, _init_directory, _init_core) |
Small, mostly self.dir; proves the pattern end to end |
| 3 | sip_readers.py |
Small, read-only, few cross-calls |
| 4 | sip_runners.py |
Mostly exec strings |
| 5 | sip_sisl_io.py (+ sisl_replica_for_projection re-export) |
Self-contained I/O |
| 6 | sip_dftb.py (+ dftb_charge re-export) |
Self-contained cluster |
| 7 | sip_fdf.py |
Large but mostly self-contained |
| 8 | sip_geometry.py |
Contains set_struct, the keystone everything reads |
| 9 | sip_electrodes.py |
Medium coupling |
| 10 | sip_transport.py |
Most entangled; global SE_func, lazy SiP import, conditional Gf_Module import |
| 11 | __init__ refactor: body extraction (§4.3 step 1), verify |
No-API-change win |
| 12 | __init__ refactor: SiPConfig (§4.3 steps 2–3), verify |
Optional, revertable |
Start with methods that make no self.<method>() calls and touch few attributes — these convert
with a pure self→sip pass and no reasoning. Candidate openers:
dQ, delete_fdf, is_siesta_done, get_fermi_level, get_potential_energy, mulliken,
get_RS_pos, is_RSSE, which_type_SE, set_parallel_k, E_field, set_methfessel_paxton,
fatband_fdf, fois_gras, manual_H, read_minimal_TSHS, read_rssi_surface_TSHS, add_stuff,
create_added_stuff_dic, create_mixed_pseudo_dic, add_mixed_pseudo_dic, set_synthetic_mixes,
add_elecs, plot_calculated_bands, gen_basis, Check_Distances.
Leave methods that call many other methods (fdf, run_TS_RSSE_calc, projection,
calculate_bulksurf_RSSE, Real_space_*) for last within their module. For each method: apply
§2.2, then confirm the §8 checks are green before moving on.
- API-surface diff: snapshot
sorted(dir(SiP))and theinspect.signatureof all 137 methods before the refactor; after each step the diff must be empty. - Import check:
from siesta_python.siesta_python import SiP, dftb_charge, sisl_replica_for_projection, rotation_matrix, error, no_remall still import. (load_calculationwas removed in §0 — drop it from the old check list.) - Structural lint:
python3 -m pyflakes siesta_python/siesta_python.py siesta_python/siesta_python/sip_*.py— must show no undefined names (unused-locals/imports noise is pre-existing; a new one is a signal). - Smoke test (temp cwd): create a dummy
../ppwith<Sym>.<xc>.psffor the species used (get_pseudo_pathssymlinks from it); constructSiPon a small cell →fdf()→ assertRUN.fdfcontents →make_bandpath()→to_sisl()→pickle()/unpickle roundtrip →clone(). Output must be identical before/after. - External caller check: grep Zandpack/GrainBoundary for
sip.<method>; confirm every called method still exists (names unchanged by design, but verify). - Constructor call check (before §4.3 step 3): grep for
SiP(calls passing more than 3 positional arguments. In-tree callers all use exactly(lat, pos, s)+ keywords (verified), but confirm external code before switching to**overrides. - Parallel-path check (after moving
Real_space_SE/Real_space_SI): run oneparallel_E=Truecase — theglobal SE_funcjoblib path depends on the function's module-qualified name. - End-to-end (final): one real siesta + tbtrans run through the refactored class.
- Renaming attributes/methods to consistent snake_case (breaking change).
- A
SiPStatedataclass for the 167-style attribute set (would touchfuncs.pyand external code). - Compressing the
.SiPpickle format (needs the ~15 readers updated; §6.3). - Changing the destructive
overwrite=Truedefault. - Folding
funcs.py's free functions into the domain modules (they already take the object; low value, do last if ever). - Removing the wrapper methods so the free functions become the public API (breaking change).
wd = os.getcwd()captured at import time (cwd-dependent global state).error()usesassert 1==0, which is a no-op underpython -O.