diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..021f42d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + # Only the display-free modules are installed and tested. The GUI extra + # needs wxPython, Mayavi and a display, and the tabs additionally import + # the `modules` simulation-I/O package, which is not distributed with this + # repository -- see README.md. + - name: Install + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Test + run: python -m pytest tests/ -v + + - name: Doctests + run: | + python -m pytest --doctest-modules \ + src/cme/naming.py \ + src/cme/cosmology.py \ + src/cme/config.py \ + src/cme/writers/music.py \ + src/cme/writers/gadget.py \ + src/cme/writers/submit.py \ + src/cme/science/candidates.py + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install ruff + run: python -m pip install ruff + + - name: Lint + run: ruff check . + + # Scoped to the display-free modules and the tests. The TraitsUI tabs are + # not yet formatted: reflowing those nested view declarations would bury + # the behavioural changes in this branch under whitespace. + - name: Format check + run: | + ruff format --check \ + src/cme/naming.py \ + src/cme/cosmology.py \ + src/cme/shell.py \ + src/cme/config.py \ + src/cme/writers/ \ + src/cme/science/ \ + tests/ + + syntax: + # The GUI modules cannot be imported without a display or the `modules` + # package, but they must at least parse on every supported version. + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Compile all modules + run: python -m compileall -q src/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f817f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Byte-compiled / cache +__pycache__/ +*.py[cod] +*$py.class + +# Packaging / distribution +build/ +dist/ +*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test and lint caches +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Editor / OS cruft +.DS_Store +*.swp +.idea/ +.vscode/ + +# Generated simulation artifacts. +# The Gadget tab writes its driver script into the working directory +# (see cme/ui/gadget.py), and MUSIC leaves noise and scratch files behind. +rungadget.sh +wnoise_* +temp_* +ics.[0-9]* +ExpansionList* + +# Generated configuration. These are outputs of the Initial Conditions and +# Gadget tabs, reproducible from the GUI state, and belong with the run +# directory rather than in version control. +*.conf +param.txt +param_sub.txt +Config.sh +Config_sub.sh +runscript +runscript_sub + +# Run outputs +outputs/ +OUTPUT +ERROR +OUTPUTsub +ERRORsub +*.o[0-9]* +*.e[0-9]* diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..997f55c --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,425 @@ +# CME codebase audit + +Assessment of the repository as of 2026-07-27, covering 11 Python modules (~180 KB, +~4,000 lines) plus 13 screenshots. Line references are to the pre-uplift `master` +(`6067f9f`); the modules have since moved into `src/cme/` and been renamed, so use the +descriptions rather than the line numbers to locate anything. + +> [!NOTE] +> **Most of this has now been addressed** on the `uplift/modernise-and-fix` branch. See +> [Resolution status](#resolution-status) at the end for what was fixed, what was found +> during the work, and what is still outstanding. The findings are kept in full because +> they are the rationale for the changes and the reference for the regression tests. + +## Verdict + +CME is a well-scoped piece of scientific tooling — the pipeline it automates is genuinely +intricate, and the tabbed workflow maps onto it sensibly. But as checked out it **cannot +run**, and it has not been able to run on a stock environment for roughly a decade. The +important structural problem is not the age of the dependencies; it is that every piece of +science and every file-format writer is welded to a GUI event handler, so nothing can be +tested, scripted, or reused without a display and a cluster. + +The first decision is not technical: **decide whether this repository is an archive or a +project.** The two paths diverge immediately, and most of the work below is only worth doing +on the second one. Sections 1–5 are the findings; section 6 proposes a reorganisation and +section 7 a prioritised plan. + +--- + +## 1. Blocking issues — the code cannot execute + +| # | Issue | Evidence | +| --- | --- | --- | +| 1.1 | **The `modules` package is missing from the repository.** Every module imports it, directly or via `Common.py`'s star import. There is no vendored copy, submodule, or install instruction. | `Common.py:31-36`, `ics.py:5`, `contam.py:2`, `mergertree.py:2`, `gadgetrun.py:2` | +| 1.2 | **Python 2 only.** 4 of 11 modules fail to parse on Python 3 (`print` statements, `xrange`). | `gadgetrun.py:291`, `halos.py:400`, `ics.py:255`, `install.py:50` | +| 1.3 | **Retired Traits namespace.** The `enthought.*` namespace packages were dropped in ETS 4 (2011). No current release provides `enthought.traits.api` or `enthought.traits.ui.wx.editor`. | `Common.py:5-16` | +| 1.4 | **wxPython backend.** `matplotlib.backends.backend_wxagg` and the `NavigationToolbar2Wx` API used here predate matplotlib 2.x; TraitsUI has since moved to Qt as its practical default. | `Common.py:2-3`, `Common.py:63` | +| 1.5 | **The Install tab builds from a `lib/installs/` tree that is not in the repository.** Every install button `chdir`s into a hardcoded relative path (`./lib/installs/fftw-2.1.5/` etc.) that does not exist. | `install.py:49,95,141,157,170,184` | + +Items 1.1 and 1.5 mean that even a perfect Python 2.7 + EPD environment would not get you a +working application. + +--- + +## 2. Correctness defects + +These are independent of the environment problems — they are bugs in the logic as written. +Grouped by confidence. + +### 2.1 Certain (would raise, or silently do nothing) + +| # | Defect | Location | +| --- | --- | --- | +| 2.1.1 | **SLURM submission raises `TypeError`.** `SLURMcores`, `SLURMtime` and `SLURMmemory` are integer traits concatenated directly with strings. The PBS path is the only known-good one. | `gadgetrun.py:320,323,325` | +| 2.1.2 | **The Gadget parameter sweep never executes.** `__init__` assigns `self.nvir = [...]` but the trait is `nrvir`; Traits silently creates a new attribute, leaving `nrvir` an empty list, so every `for nrviri in self.nrvir` loop body is skipped. | `gadgetrun.py:845` vs `:103` | +| 2.1.3 | **Four state updates are no-ops** — `==` written where `=` was meant, so `makeactive` never changes and the affected controls stay disabled. | `contam.py:272,275,281,284` | +| 2.1.4 | **`gethostid()` raises `UnboundLocalError` when no host matches**, but callers test `if idhost:` as though it returns a falsy value. | `halos.py:130-140`, used at `:188,215` | +| 2.1.5 | **`gethalos_xy()` raises `NameError` on the parent full-box path**, because the `'pos' in halo_varx` block reads `idhost`, which only exists in the zoom branch. The same function returns unbound `x, y` when `dataexists` is false. | `halos.py:220-224`, `:231` | +| 2.1.6 | **`_plot_button_fired` does nothing in parent mode.** The entire plotting block is indented inside the `elif self.parentorzoom == 'zoom'` branch, so the parent branch computes `dataexists` and falls through. | `halos.py:234-285` | +| 2.1.7 | **The Odyssey cluster preset never applies.** `self.clustopt = 'odyssey'` — typo for `clusteropt`; Traits accepts it as a new attribute, so the cluster stays at its default. | `header.py:75` | +| 2.1.8 | **`Clear` corrupts the ICs tab.** `self.main.mergertreetab.initstab = []` sets a stray attribute on the wrong object; the intent was `self.main.initstab.haloid = []`. | `candidates.py:148` | +| 2.1.9 | **Three MUSIC Poisson settings are never applied.** `__init__` writes `self.pre_smooth`, `self.post_smooth` and `self.grad_order`, but the traits are `presmooth`, `postsmooth`, `gradorder`. Compounding this, `presmooth`/`postsmooth` are declared as plain class integers rather than traits, yet the view builds `Item`s for them. | `ics.py:698-699,705` vs `:75-76,78` | +| 2.1.10 | **`nhalo` can be unbound**, if the selected halo ID is absent from the candidate file. | `ics.py:533-535` | +| 2.1.11 | **`determineboolstr` returns unbound** for any input that is not exactly `True` or `False` (e.g. a numpy bool). | `ics.py:889-895` | +| 2.1.12 | **A run script is opened before its directory is known to exist** — the `open(filepath + "/runscript")` sits outside the `else` that guards on the path being found, so a missing path raises `IOError` after having already printed "PATH NOT FOUND". | `gadgetrun.py:290-314` | + +### 2.2 Wrong results (runs, but the output is incorrect) + +| # | Defect | Location | +| --- | --- | --- | +| 2.2.1 | **3D halo distribution plots y twice instead of z**: `z = tmphalos['posY']`. Every full-box Mayavi scene is projected into a plane. | `halos.py:369` | +| 2.2.2 | **Reported host x-position is actually its z-position**: `self.hostposx = host['posZ']`. | `halos.py:201` | +| 2.2.3 | **A phantom zero-mass candidate contaminates every selection.** `candidatearr` is initialised to `np.zeros((1,6))` and results are `vstack`ed onto it, so row 0 is always `[0,0,0,0,0,0]` — it is plotted, counted in the array view, and accepted by the "add halo" membership test. | `candidates.py:314,217,124-125` | +| 2.2.4 | **Lagrangian pointer filenames embed a Python list literal.** `'NRVIR' + str(self.nrvir)` on a `List` trait yields `H190897NRVIR['1']` — visible in `screenshots/constructICs.png`. The actual file is written with `int(self.nrvir[0])`, so the displayed and real paths disagree. | `ics.py:340-341,346-347,352-353` vs `:449,665,675` | +| 2.2.5 | **The SLURM execute line is built from the PBS core count**, so a SLURM job launches with `PBSncores` ranks. | `gadgetrun.py:333` | +| 2.2.6 | **`_SLURMcores_changed` has two identical branches**, so enabling SUBFIND does not switch SLURM to the `_sub` binary and `param_sub.txt` the way the PBS path does. | `gadgetrun.py:433-437` | + +### 2.3 Dead or unreachable code + +- `main.py:98-99` — `_analysistab_default` instantiates `Analysis`, whose import is commented out at `main.py:8`. Not currently reachable, but it is a latent `NameError`. +- `halos.py:57` — `halo_type` offers three values, but four branches are written; `'subs of ID'` can never be selected (`halos.py:180,300,364`). +- `halos.py:54-55` — `boxtype` is declared twice; the second declaration silently wins. +- `gadgetrun.py:226` — the view builds `Item('clusteropt')` but the trait declaration is commented out at `gadgetrun.py:5`; the attribute only appears via assignment at `:927`. +- `candidates.py:44` (`upper`), `:17` (`id_select`), `Common.py:33` (`readids`) are never used. + +--- + +## 3. Security and robustness + +| # | Issue | Detail | +| --- | --- | --- | +| 3.1 | **Command injection / breakage via string-concatenated shell commands.** 70 call sites use `os.system` or `subprocess.call(..., shell=True)` with GUI-supplied paths interpolated in. A path containing a space silently splits; one containing `;` or `$(...)` executes. | `install.py` (all buttons), `ics.py:257,325`, `gadgetrun.py:302,306,309,362,368,370,406` | +| 3.2 | **`rm` commands built from unvalidated paths.** `f1.write("rm " + str(filepath) + "/*.e*\n")` and `rm wnoise* temp*` run in a directory derived from GUI state. If a path trait is empty, the glob widens. | `gadgetrun.py:374-375`, `ics.py:322` | +| 3.3 | **A fragile offset into a third-party file.** `tail -n+96 .../Config.sh` assumes the P-Gadget3 config has exactly 95 lines of preamble. Any upstream change silently corrupts the generated config. | `gadgetrun.py:359,365` | +| 3.4 | **Personal identifiers hardcoded in source** — 19 occurrences: two cluster login names, four specific hostnames, three absolute paths under one user's home directory, and two email addresses. Also `halos.py:131`, an absolute path baked into a method body. | `header.py:55-99`, `gadgetrun.py:9-10,123,257,263`, `halos.py:131` | +| 3.5 | **Every file handle is unprotected by `with`.** ~15 `open()`/`close()` pairs leak on exception, and `_getcandidates_button_fired` can leave `out` open on any error mid-loop. | `ics.py:666,676,743,837`, `gadgetrun.py:253,312,657,664`, `candidates.py:175` | +| 3.6 | **`open()` used as an existence test**, in a `try`/`except IOError`, rather than `os.path.exists`. Race-prone and obscures intent. | `ics.py:374-378,450-451,515-519`, `gadgetrun.py:813-817` | + +--- + +## 4. Structural problems + +### 4.1 The science is trapped inside event handlers + +Every computation lives inside a `_*_button_fired` or `_*_changed` method that also clears +a figure, sets axis labels, and calls `wx.CallAfter`. There is no function you can call to +select candidates, extract a Lagrangian region, or emit a MUSIC config. Consequences: + +- Nothing can be unit tested — you need a display and a live parent simulation to exercise + a mass cut. +- Nothing can be batch-scripted. A 2,000-halo suite is driven by clicking. +- Bugs like 2.1.6 (a mis-indented plotting block) are invisible, because the logic and the + rendering are one blob. + +This is the single highest-value thing to change, and it is the organising idea behind the +layout proposed in section 6. + +### 4.2 Massive duplication + +| Duplicated thing | Occurrences | Where | +| --- | --- | --- | +| The `H..._B..._Z..._P..._LN..._LX..._O..._NV...` folder name | **18** | `contam.py` ×13, `halos.py` ×2, `gadgetrun.py` ×2, `ics.py` ×1 | +| `dircheck = ... + "/outputs/groups_0" + snapnum` followed by an existence test | 9 | `contam.py:209-284,426` | +| The `if self.projopt == 'xy' / 'xz' / 'yz'` axis-selection block | 3 | `ics.py:391-419,462-490,613-641` | +| The 12-line softening cascade | 2 | `gadgetrun.py:522-533,543-554` | +| The `mpirun -np ... ./P-Gadget3 ...` execute-line builder | 18 copies across 6 handlers, plus 2 in `__init__` | `gadgetrun.py:415-453,928-929` | +| The hardcoded Hubble parameter `0.6711` | 6 | `candidates.py:151,305`, `contam.py:93,316,385`, `halos.py:400` | +| Near-identical `_*_changed` handlers that clear the figure | 4 | `mergertree.py:85-123` | + +`contam.py` is the clearest case: 8 of its 12 methods begin with the same 8-line folder-name +construction followed by the same existence check. Extracting one `run_dir_name()` function +and one `outputs_exist()` predicate removes roughly 150 lines and eliminates the class of bug +where one copy is updated and the others are not. + +The duplicated Hubble constant is a scientific correctness risk, not just untidiness: the +value appears both as a trait (`candidates.py:305`) and as five hardcoded literals, one of +which (`halos.py:400`) is inside a debug `print`. + +### 4.3 Star imports and accidental dependencies + +`Common.py` performs six star imports (`enthought.traits.api`, `enthought.enable.api`, +`matplotlib`, `grifflib`, plus module-level `numpy`/`wx`/`os`) and every tab does +`from Common import *`. Nothing declares what it actually uses. + +The concrete hazard: `ics.py` uses `patches.Rectangle` (`:422,495,646`) without importing +`matplotlib.patches`. It resolves only because `from matplotlib import *` at `Common.py:19` +runs after `matplotlib.pyplot` has been imported at `:1`, which populates +`matplotlib.patches` as a side effect. Reorder those two lines and the ICs tab breaks. + +Separately, `ics.py:5` aliases a project module to `re` — shadowing the standard library +`re` for that module. + +### 4.4 Coupling through `self.main` + +Each tab holds a back-reference to `ApplicationMain` and reaches through it to mutate +siblings — `candidates.py:135-139` writes into five other tabs. This makes the initialisation +order load-bearing (`main.py:78-105` must construct `headertab` before anything that reads +`headertab.datamasterpath`, and `candidatestab` before the four tabs that copy its `haloid` +list) and there is no mechanism enforcing it. Traits' own notification system (`@on_trait_change`, +or a shared model object that tabs observe) is the idiomatic fix. + +Note also that the halo sample is *copied* at construction time (`self.haloidlist = +self.main.candidatestab.haloid`), which is why `candidates.py` has to manually push updates +into each tab afterwards. + +### 4.5 Performance + +- **`ics.py:564-601`** re-reads the parent snapshot's entire `POS` and `ID` blocks, plus the + IC file's `POS`/`ID`, once per `nrvir` value. For a 512³ parent that is ~4 GB of I/O per + iteration, when one read outside the loop would serve all of them. +- **`candidates.py:180-209`** rebuilds `np.array(np.float64(MWcand['posX']))` (and five + siblings) inside the per-candidate loop, making the selection O(N²) in the base sample. + Hoisting six lines makes it O(N). +- **`candidates.py:223-237`** re-plots the full candidate set on every loop iteration, because + the plotting block sits inside the `for` loop rather than after it. +- **`candidates.py:217`** grows the result array with `np.vstack` per match — quadratic + copying. Append to a list, `np.array()` once. + +### 4.6 Style inconsistencies + +- `gadgetrun.py` is indented with 2 spaces; every other module uses 4. +- Naming mixes `lowercase` (`haloid`), `camelCase` (`lagrPos`), `PascalCase` (`InitCondFile`, + mirroring Gadget's parameter names — defensible), and `UPPER_CASE` traits (`PMGRID`). +- `if x == True:` throughout, rather than `if x:`. +- Deep view nesting: `halos.py:100-104` reaches eight `HGroup`/`VGroup`/`Group` levels, several + of them redundant single-child wrappers. +- ~200 lines of commented-out code, including whole blocks (`header.py:35-49`, + `gadgetrun.py:491-512`, `ics.py:776-807`, `halos.py:425-431`). Git history serves this + purpose; the comments only obscure the live logic. + +--- + +## 5. Project hygiene + +| Missing | Impact | +| --- | --- | +| `requirements.txt` / `pyproject.toml` | No reproducible environment. The dependency list must be reverse-engineered from imports — which is how the README's list was produced. | +| Any test | No way to verify a refactor. Nothing is currently testable anyway (§4.1). | +| CI configuration | Nothing catches a syntax error on commit. | +| `LICENSE` | All rights reserved by default, so the code is not legally reusable despite the README inviting collaborators. | +| `.gitignore` | `__pycache__`, `*.pyc`, and generated `.conf`/`param.txt`/`rungadget.sh` artifacts are all untracked-but-unignored. `gadgetrun.py:253` writes `rungadget.sh` into the repository root. | +| Docstrings | One in the entire codebase (`main.py:64`). | +| `CHANGELOG` | 30+ commits are "Update README.md"; there is no record of behavioural change. | + +--- + +## 6. Proposed reorganisation + +The layout follows from §4.1: pure computation and pure text generation move out of the GUI, +into modules that can be imported, tested and scripted without a display. + +``` +cme/ +├── pyproject.toml # deps + packaging (replaces "install EPD") +├── README.md +├── AUDIT.md +├── LICENSE +├── .gitignore +├── clusters.toml # cluster profiles — replaces hostname if-chains +├── src/cme/ +│ ├── app.py # was main.py — window assembly only +│ ├── naming.py # run_dir_name(), lagr_paths(), outputs_exist() +│ │ # ← the 18 duplicated folder-name builders +│ ├── cosmology.py # named cosmologies + ONE Hubble constant +│ ├── config.py # loads clusters.toml; no hostnames in code +│ ├── io/ # the former `modules` package, vendored & pinned +│ │ ├── snapshots.py # readsnap, readsnapHDF5 +│ │ ├── halos.py # RSDataReader, readsubf +│ │ └── trees.py # MTCatalogue +│ ├── science/ # NO plotting, NO traits — plain functions on arrays +│ │ ├── candidates.py # select(catalogue, mass_range, zones) -> DataFrame +│ │ ├── lagrangian.py # region(parent, halo, nrvir) -> Region +│ │ ├── contamination.py # profile(catalogue, centre) -> (r, n, m) +│ │ └── softening.py # from_levelmax(boxsize, lmax) -> Softenings +│ ├── writers/ # pure string/file emitters — templates, not f.write chains +│ │ ├── music_conf.py # was ics.py:738-887 +│ │ ├── gadget_param.py # was gadgetrun.py:653-769 +│ │ ├── gadget_config.py # was gadgetrun.py:556-651 +│ │ └── submit.py # PBS + SLURM from one template each +│ ├── shell.py # subprocess wrapper: argument lists, no shell=True +│ └── ui/ # thin HasTraits tabs — bind widgets, call the above +│ ├── common.py # explicit imports; no `import *` +│ ├── home.py install.py candidates.py ics.py +│ └── gadget.py halos.py mergertree.py contamination.py +├── tests/ +│ ├── test_naming.py # 18 duplications collapse to one tested function +│ ├── test_writers.py # golden-file comparison against known-good configs +│ ├── test_candidates.py # synthetic catalogue, known answer +│ └── test_cosmology.py +├── docs/ +└── screenshots/ +``` + +Four changes carry most of the benefit: + +1. **`naming.py`** — one function for the run-directory convention. Removes ~150 lines and + the whole class of drift bugs described in §4.2. +2. **`writers/`** — the config emitters are already almost pure functions; they take scalars + and produce text. Moved out and driven by templates, they become golden-file testable, + which is the only practical defence against a silently malformed `param.txt`. +3. **`science/`** — functions over arrays, with the figure work left in `ui/`. This is what + makes the pipeline scriptable for a large suite, and it is where bugs 2.2.1–2.2.3 would + have been caught. +4. **`clusters.toml`** — cluster profiles as data. Deletes the `platform.node()` if-chains + (`header.py:55-99`) and the hardcoded paths in `gadgetrun.py:257,263`, and makes the tool + usable by someone who is not the author. + +--- + +## 7. Prioritised plan + +**P0 — Decide the repository's status.** Everything else depends on this. + +*If archiving* (a reasonable choice for a 2014 research tool): +1. The README now carries a status banner, documents the missing `modules` dependency, and + records the directory-naming convention. ✔ +2. Add a `LICENSE` so the code is legally reusable. +3. Tag a final release and mark the repository archived on GitHub. +4. Optionally record the science in a short `docs/method.md` — the selection criteria and + contamination metric are the durable contribution, and they currently exist only as + GUI callbacks. + +Stop there. P1 onward is only worth it if the tool is to be used again. + +*If reviving:* + +**P1 — Make it run** (largest effort; nothing else can be verified until this is done) +1. Vendor or pin the `modules` package (§1.1). Without it there is no way to test anything. +2. Python 2 → 3: `pyupgrade`/`2to3` handles `print` and `xrange`; integer division and + `dict.keys()` need review by hand. +3. `enthought.traits.*` → `traits` / `traitsui`; wx → Qt (PySide6) for both TraitsUI and the + matplotlib canvas in `Common.py:41-71`. +4. Add `pyproject.toml` with pinned versions. Verify `pandas`' `.ix` accessor usage + (`halos.py:192,313`, `ics.py:537-541`) — removed in pandas 1.0, replace with `.loc`. +5. Remove the `install.py` tab, or point it at the system package manager. Hand-building + FFTW2 in 2026 is not the right answer, and the source tree it expects is absent anyway. + +**P2 — Extract and test** (do this *before* fixing bugs, so fixes are verifiable) +1. `naming.py` + `test_naming.py`; replace all 18 call sites. +2. `cosmology.py` with a single Hubble constant; replace all 6 literals. +3. `writers/` + golden-file tests, using a known-good `param.txt` and `.conf` from an existing + run directory as the reference. +4. `science/candidates.py` + a synthetic-catalogue test. +5. GitHub Actions running the suite. + +**P3 — Fix the defects in §2**, each with a regression test. Order: 2.1.1–2.1.3 and 2.1.6 +(features that are simply inert), then 2.2.1–2.2.5 (wrong output), then the rest. + +**P4 — Harden** +1. `shell.py`: argument lists, no `shell=True`, no interpolated paths (§3.1–3.3). +2. `clusters.toml`; strip the hardcoded identifiers in §3.4. +3. `with` for every file handle; `os.path.exists` for existence tests. +4. Hoist the loop-invariant reads in §4.5 — the `ics.py` one is a several-GB-per-iteration win. + +**P5 — Polish** +1. `.gitignore`; stop writing `rungadget.sh` into the repository root. +2. Delete the ~200 lines of commented-out code and the dead branches in §2.3. +3. Normalise `gadgetrun.py` to 4-space indentation; run `black` and `ruff` across the tree. +4. Docstrings on the `science/` and `writers/` functions — those are the reusable API. +5. Flatten the redundant single-child view groups. + +--- + +## Appendix: metrics + +| Metric | Value | +| --- | --- | +| Python modules | 11 | +| Lines of Python | ~4,000 | +| Modules that fail to parse on Python 3 | 4 / 11 | +| Shell invocations (`os.system` / `shell=True`) | 70 | +| Duplicated folder-name constructions | 18 | +| Hardcoded personal paths / usernames / emails | 19 | +| Hardcoded copies of the Hubble parameter | 6 | +| Tests | 0 | +| Docstrings | 1 | +| Largest modules | `gadgetrun.py` (49 KB), `ics.py` (47 KB) | + + +--- + +## Resolution status + +Addressed on `uplift/modernise-and-fix`, in 17 commits. 171 tests now cover the +display-free library. + +### Fixed + +| Finding | How | +| --- | --- | +| 1.2 Python 2 only | Ported: 35 print statements, 6 `xrange`, 21 pandas `.ix` → `.loc`, five invalid escapes. All modules parse on 3.9-3.12. | +| 1.3 Retired `enthought.*` namespace | Mapped onto `traits` / `traitsui` / `enable`. wxPython kept as the toolkit. | +| 2.1.1 SLURM `TypeError` | `cme.writers.submit` formats rank counts with `int()`. Regression test. | +| 2.1.2 Gadget sweep inert (`nvir` typo) | Corrected to `nrvir`. | +| 2.1.3 Four `makeactive ==` no-ops | Removed with the handler dedup in the contamination tab. | +| 2.1.4 `gethostid` `UnboundLocalError` | Returns `None`; tolerates a missing summary file. | +| 2.1.5 `gethalos_xy` `NameError` / unbound return | Restructured; returns empty arrays on a miss. | +| 2.1.6 Plot button inert in parent mode | Plotting block de-indented out of the zoom branch. | +| 2.1.7 Odyssey preset never applied (`clustopt` typo) | Replaced wholesale by `clusters.toml`. | +| 2.1.8 `Clear` wrote to the wrong object | One `_publish_sample()` for all five consumers. | +| 2.1.9 Three MUSIC settings never applied | Corrected names; `presmooth`/`postsmooth` are now real traits. | +| 2.1.10 `nhalo` unbound | Vectorised lookup that reports the miss. | +| 2.1.11 `determineboolstr` unbound return | Replaced by a total function in `cme.writers.music`. | +| 2.1.12 Run script opened before its directory | `writers.write` creates parents, called once the path is known. | +| 2.2.1 3D distribution plotted y twice | `z = tmphalos['posZ']`. | +| 2.2.2 `hostposx` reported z | `host['posX']`. | +| 2.2.3 Phantom zero-mass candidate | `select()` returns an empty array; the add-halo guard no longer tests for the sentinel. | +| 2.2.4 `NRVIR['1']` in displayed paths | Routed through `naming.lagr_region_path`. | +| 2.2.5 SLURM ranks from PBS core count | One `execute_command()` per scheduler. | +| 2.2.6 SUBFIND ignored on SLURM | Same. | +| 2.3 Dead code | `Analysis`, duplicate `boxtype`, 3 unused imports, 5 unused buttons, 20 dead locals, ~40 lines of commented-out code removed. | +| 3.1-3.3 Shell injection (70 sites) | `cme.shell` runs argument lists; no `shell=True` or `os.system` remains. The `tail -n+96` splice is done in Python. | +| 3.4 19 personal identifiers | Moved into `clusters.toml`. | +| 3.5-3.6 Unprotected file handles | No raw `open()` outside `cme.writers`; existence checks use `os.path.exists`. | +| 4.1 Science trapped in event handlers | `cme.naming`, `cme.cosmology`, `cme.science`, `cme.writers` are display-free and tested. | +| 4.2 Duplication | 18 folder-name builders → `cme.naming`; 6 Hubble literals → `cme.cosmology`; 18 mpirun strings → one method; softening cascade, projection block and 4 identical handlers all collapsed. | +| 4.3 Star imports | Explicit imports plus `__all__` in `cme/ui/common.py`. The accidental `patches` dependency is now explicit. | +| 4.5 Performance | Parent snapshot reads hoisted out of the nrvir loop (~30 GB of I/O for a five-value sweep); candidate extraction made O(N); `vstack`-per-match replaced. | +| 5 Hygiene | `pyproject.toml`, `LICENSE`, `.gitignore`, CI, 171 tests, docstrings on the library. | + +### Found during the work, also fixed + +- Snapshot indices were zero-padded by concatenation (`"groups_0" + str(n)`), correct only + for two-digit numbers. Snapshot 5 looked for `groups_05` where Gadget writes `groups_005`, + and the zoom tabs' default of 255 produced `snapdir_0255`. +- The contamination and halo tabs hardcoded `_Z127` rather than reading their own `zinit`, + so a run generated at another starting redshift was looked up in a directory that had + never been created. +- Base paths were joined as `self.gadpath + 'halos/H'`, silently producing + `.../datahalos/H190897` whenever the path trait lacked a trailing slash. +- `for lmini in self.lmin` iterated an Enum holding a *string*, so a levelmin of 10 or above + ran the sweep twice, with levelmin `'1'` and then `'0'`. +- `HubbleParam` was stored as H0 in a trait named for h and divided by 100 on the way out, + so the value displayed in the GUI disagreed with the value written to disk. +- `Config.sh` emitted `TOKEN# comment` with no separating space for tokens longer than the + comment column. +- The Gadget existence table hardcoded snapshot 63 regardless of the requested output count. +- `ApplicationMain.__init__` never called `HasTraits.__init__`, so trait initialisation was + skipped and kwargs were discarded. +- `_clusteropt_changed` and `_username_changed` derived *different* path sets from the same + inputs, so which paths you got depended on the order you touched the fields. +- `reWriteIC` was imported as `re`, shadowing the standard library module. + +### Outstanding + +1. **The `modules` I/O package is still absent** (finding 1.1). Nothing else in this list + matters as much: the GUI cannot start without it. Vendor or pin it. +2. **The GUI has no test coverage.** The library is well covered; the tabs are verified only + by `compileall` and inspection, because exercising them needs a display and a real parent + simulation. A Qt port would make headless widget tests feasible. +3. **wxPython → Qt** (finding 1.4). Deliberately deferred: it is a large diff that cannot be + verified here, and it would have obscured the behavioural fixes. +4. **The Install tab** (finding 1.5) still expects a `lib/installs/` source tree that is not + in the repository. It is hardened but should probably be deleted in favour of the system + package manager. +5. **`self.main` coupling** (finding 4.4). Tabs still reach through a back-reference to + mutate siblings, so construction order remains load-bearing. +6. **`PARENT_SNAPSHOT` / `PARENT_ICS`** in `cme/ui/ics.py` are still hardcoded to the + Caterpillar 512³ parent; they belong in `clusters.toml`. +7. **The non-PLANCK cosmologies** in `cme.cosmology` were taken from the cited papers, not + from the original `grifflib`. PLANCK cross-checks exactly against the values that were + hardcoded in the Gadget tab; the others should be diffed against `grifflib` if it is + recovered, before being used for new science. +8. **TraitsUI view declarations** are still deeply nested (up to eight redundant group + levels) and unformatted, and `cme/ui/` is excluded from the `ruff format` check. diff --git a/Common.py b/Common.py deleted file mode 100644 index 15010f0..0000000 --- a/Common.py +++ /dev/null @@ -1,71 +0,0 @@ -from matplotlib.pyplot import Figure -from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas -from matplotlib.backends.backend_wx import NavigationToolbar2Wx - -from enthought.traits.api import * -from enthought.traits.ui.api import View,UItem, Item,Group, Heading, Label, \ - HSplit, Handler, CheckListEditor, EnumEditor, TableEditor, \ - ListEditor, Tabbed, VGroup, HGroup, RangeEditor, Spring, spring -from enthought.traits.ui.menu import NoButtons -from traitsui.ui_editors.array_view_editor import ArrayViewEditor -#from traits.api import Array -#from enthought.traits.api import Any, Instance -from enthought.traits.ui.wx.editor import Editor -from enthought.traits.ui.wx.basic_editor_factory import BasicEditorFactory -import numpy as np -from enthought.enable.api import ColorTrait -from traitsui.api import TabularEditor -from traitsui.tabular_adapter import TabularAdapter -from matplotlib import * - -import os -import sys -import wx -import platform -import socket -import random -import subprocess - -import pylab as plt - -import modules.readsnapshots.readsnapHDF5 as rsHD -import modules.readsnapshots.readsnap as rs -import modules.readsnapshots.readids as readids -import modules.readhalos.readsubf as readsubf -import modules.readhalos.RSDataReaderv2 as RSDataReader -from modules.brendanlib.grifflib import * - -from random import randint - - -class _MPLFigureEditor(Editor): - - scrollable = True - - def init(self, parent): - self.control = self._create_canvas(parent) - self.set_tooltip() - - def update_editor(self): - pass - - def _create_canvas(self, parent): - """ Create the MPL canvas. """ - # The panel lets us add additional controls. - - panel = wx.Panel(parent, -1, style=wx.CLIP_CHILDREN) - sizer = wx.BoxSizer(wx.VERTICAL) - panel.SetSizer(sizer) - - # matplotlib commands to create a canvas - mpl_control = FigureCanvas(panel, -1, self.value) - sizer.Add(mpl_control, 1, wx.LEFT | wx.TOP | wx.GROW) - toolbar = NavigationToolbar2Wx(mpl_control) - sizer.Add(toolbar, 0, wx.EXPAND) - self.value.canvas.SetMinSize((10,10)) - - return panel - -class MPLFigureEditor(BasicEditorFactory): - - klass = _MPLFigureEditor \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dafbf18 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2014-2026 Brendan Griffen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bd0e0f4..2d5843f 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,314 @@ -CME: Caterpillar Made Easy -=== +
-Interactive suite to do the following tasks for the Caterpillar Project being carried out at MIT/Harvard: +# CME — Caterpillar Made Easy -* generate list of candidates based on tunable isolation and merger history criteria -* inspect lagrangian region properties (e.g. size, volume, morphology) -* contruct initial conditions for cosmological simulations using MUSIC (Hahn & Abel 2010). -* run cosmological simulations using P-Gadget3. -* examine contamination of low-resolution particles in resimulation across suite -* create heatmaps of contamination in every projection -* do 3D visualisations of halo and particle velocity field and positions -* submit job via both PBS and SLURM submission systems. +An interactive desktop GUI for setting up, running and inspecting cosmological zoom-in simulations. -Not a single line of code is required by the user. +![Python](https://img.shields.io/badge/python-3.9%2B-blue) +![Tests](https://img.shields.io/badge/tests-171-brightgreen) +![GUI](https://img.shields.io/badge/GUI-Traits%20%2B%20wxPython-orange) +![Viz](https://img.shields.io/badge/3D-Mayavi-8a2be2) +![License](https://img.shields.io/badge/license-MIT-green) -The packaged utilizes a number of suites which come with EPD distribution (e.g. Traits, Pandas) which is required for the package to work. A number of read modules are not included for Gadget files and the Rockstar halo catalogues. Although it is for the *Caterpillar Project*, it can in principle work on any cosmological simulation suite. +
-Please contact [brendan.f.griffen@gmail.com](mailto:brendan.f.griffen) if you would like to use this software for your simulation suites. +CME wraps the entire zoom-in resimulation pipeline — candidate halo selection, Lagrangian +region extraction, MUSIC initial conditions, P-Gadget3 parameter files, cluster job +submission, and contamination analysis — behind a single Traits/TraitsUI window, so a whole +suite of resimulations can be configured and launched without writing code. It was built for +the [Caterpillar Project](https://www.caterpillarproject.org) at MIT/Harvard, but the +workflow applies to any suite built on MUSIC + Gadget + Rockstar. -## Examples +> [!IMPORTANT] +> **The GUI cannot run from a clean checkout.** Every tab imports a `modules` package of +> simulation I/O readers that is **not distributed with this repository** — see +> [Prerequisites](#prerequisites). Launching the interface also needs a display, a built +> MUSIC and P-Gadget3, and Rockstar catalogues for a parent simulation. +> +> What *does* work standalone is everything under [Library](#library): the path +> conventions, the cosmologies, the candidate selection, and the MUSIC/Gadget/scheduler +> file writers are display-free, `pip`-installable and covered by 171 tests. The code was +> Python 2 until recently; see [`AUDIT.md`](AUDIT.md) for the assessment that drove the +> modernisation and what remains outstanding. -#### Candidate Selection -![candidate selection](screenshots/FOFviz.png) +--- -#### Inspect & Generate Lagrangian Regions -![inspect lagrangian region](screenshots/lagrangianinspection.png) +## Features -#### Inspect Merger Trees -![inspect lagrangian region](screenshots/mergertreeinspection.png) +| Stage | Tab | What it does | +| --- | --- | --- | +| Dependencies | **Install** | Builds FFTW2/3, HDF5, GSL, zlib and szip into a local `lib/` prefix. | +| Candidate selection | **Candidates** | Scans a parent Rockstar catalogue for Milky-Way analogues using a mass range plus three tunable isolation ("exclusion zone") criteria; tabulates and exports the surviving sample. | +| Lagrangian regions | **Initial Conditions** | Traces a candidate's z=0 particles back to the parent ICs, measures the Lagrangian centre/extent/volume, visualises the region in any projection, and writes the MUSIC region point file. | +| Initial conditions | **Initial Conditions** | Generates MUSIC `.conf` files across a grid of resolutions, paddings, overlaps, region shapes and cosmologies, then invokes MUSIC for each combination. | +| Simulation setup | **Gadget** | Exposes every P-Gadget3 `param.txt` and `Config.sh` option, auto-scales gravitational softenings with `levelmax`, and designs the snapshot output list in expansion factor. | +| Progress tracking | **Gadget → Existence** | Tabulates which ICs, snapshots and halo catalogues already exist across the whole parameter sweep. | +| Job submission | **Gadget → Write & Submit** | Writes and submits PBS/Torque or SLURM run scripts, optionally recompiling Gadget first. | +| Halo analysis | **Halo Finder** | Plots any pair of Rockstar quantities for a parent box or a zoom, restricted to hosts, subhaloes or all; renders 3D halo distributions and velocity fields in Mayavi. | +| Merger histories | **Merger Tree** | Plots main-branch evolution of any tracked quantity from `consistent-trees` output. | +| Resolution quality | **Contam.** | Measures low-resolution particle contamination versus radius from the target FOF group, and maps it as a 2D heatmap in any projection or a 3D Mayavi scene. | -#### Examing Halo Distributions Interactively (via Mayavi) -![Examing Halo Distributions Interactively](screenshots/FOFviz.png) +Cosmologies available throughout: WMAP1, WMAP3, WMAP5, WMAP7, WMAP9, PLANCK. -#### Examing Halo Velocity Field Interactively (via Mayavi) -![Examing Halo Velocity Field Interactively](screenshots/velocityhaloinspection.png) +--- -#### Examine Contamination Quality -![Examine Contatmination Quality](screenshots/contaminationradial.png) +## Screenshots -#### Examine Contamination Quality (via heatmap) -![Examine Contatmination Heatmaps](screenshots/contaminationheatmap.png) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Candidate selection
Candidate selection
Lagrangian region inspection
Lagrangian region inspection
Constructing ICs across a sweep
Constructing initial conditions
Merger tree inspection
Merger tree inspection
Halo distributions in 3D (Mayavi)
FOF visualisation
Halo velocity field in 3D (Mayavi)
Halo velocity field
Inter-halo relations from Rockstar
Parent catalogue inspection
Contamination versus radius
Radial contamination
Contamination heatmap
Contamination heatmap
P-Gadget3 parameters
Gadget setup
Snapshot output list
Snapshot output list
Existence check across the sweep
Existence check
Submit to PBS or SLURM
Submit jobs
-#### Specify All Gadget Parameters (temporal resolution etc.) -![Specify All Gadget Parameters](screenshots/specifysnapshotoutput.png) +--- -#### Investigate inter-halo relations from Rockstar halo catalogue -![Investigate inter-halo relations](screenshots/inspectparams.png) +## Prerequisites -#### Run full suites with varied initial conditions -##### (e.g. to determine how resolution effects contamination rate) -![Run full suites with varying initial conditions](screenshots/existencecheck.png) +### Python environment -#### Submit jobs to cluster via PBS or SLURM -![Submit jobs](screenshots/submitjob.png) +Python 3.9 or newer. Dependencies are declared in `pyproject.toml` and split by what +you need: + +| Extra | Contents | Needed for | +| --- | --- | --- | +| *(base)* | `numpy`, `pandas` | the display-free library and the test suite | +| `gui` | `traits`, `traitsui`, `enable`, `mayavi`, `matplotlib`, `wxPython` | launching the interface | +| `dev` | `pytest`, `ruff` | running tests and linting | + +The GUI uses the modern `traits` / `traitsui` packages with the wxPython toolkit. A Qt +port is the obvious next step but has not been done — see [`AUDIT.md`](AUDIT.md). + +### External I/O package (not included) + +Every module imports a `modules` package that must be on `PYTHONPATH`. Without it, nothing +runs. It provides: + +| Import | Provides | +| --- | --- | +| `modules.readsnapshots.readsnap` | Gadget binary snapshot reader (`read_block`) | +| `modules.readsnapshots.readsnapHDF5` | Gadget HDF5 reader (`read_block`, `snapshot_header`) | +| `modules.readsnapshots.readids` | Particle ID reader | +| `modules.readhalos.RSDataReaderv2` | Rockstar catalogue reader (`RSDataReader`) | +| `modules.readhalos.readsubf` | SUBFIND group catalogue reader (`subfind_catalog`) | +| `modules.mergertrees.MTCatalogue` | `consistent-trees` merger tree reader | +| `modules.convertfiles.reWriteIC` | `getBlocks()` — post-processes MUSIC output for Gadget | +| `modules.brendanlib.grifflib` | Helpers: `getcandidatelist`, `getcentext`, `getlagrxyz`, `drawcircle`, `addsubtitle`, `placenormtext`, `CorrectPos`, `COM`. (`cosmoconstant` is no longer needed — see `cme.cosmology`.) | + +### External codes and data + +- **MUSIC** (Hahn & Abel 2011) — compiled binary, path set on the Home tab +- **P-Gadget3** — source tree at `/lib/P-Gadget3`, compiled from the Gadget tab +- **Rockstar** halo catalogues and **consistent-trees** merger trees for the parent box +- A parent simulation (the Caterpillar 512³, 100 Mpc/h box) with both `outputs/` snapshots and `ics/` +- A PBS/Torque or SLURM cluster, for job submission + +--- + +## Installation + +```bash +git clone https://github.com/bgriffen/cme.git +cd cme + +# The display-free library and the test suite: +pip install -e ".[dev]" +python -m pytest tests/ + +# To also launch the interface: +pip install -e ".[gui]" +# The simulation I/O readers are a separate, undistributed dependency: +export PYTHONPATH=/path/to/modules-parent:$PYTHONPATH +``` + +## Usage + +```bash +cme # or: python -m cme.app +``` + +The window splits into a plot panel (matplotlib, plus a Mayavi scene on its own tab) on the +left and the workflow tabs on the right. Marker colour, style and size at the bottom left +apply to every 2D plot. Work left to right through the tabs: + +1. **Home** — pick a cluster profile and confirm the derived paths to MUSIC, P-Gadget3, + the parent simulation and the project data directory. Everything downstream derives from + these. Profiles come from [`clusters.toml`](#cluster-profiles) and are matched against + the hostname automatically. +2. **Candidates** — either load an existing `candidates.dat` or run the selection, then add + the IDs you want into the working halo sample. That sample propagates to every other tab. +3. **Initial Conditions** — generate and inspect the Lagrangian region per halo and `nrvir` + value, then produce the MUSIC configs and run them. +4. **Gadget** — set parameters, build the output list, then write and submit the run scripts. +5. **Halo Finder / Merger Tree / Contam.** — analyse the results. + +### Run directory naming + +Every resimulation lives at `/halos/H//`, where the folder +name encodes the full parameter set: + +``` +H_B_Z_P_LN_LX_O_NV +``` + +For example, `H190897_BE_Z127_P7_LN7_LX11_O4_NV4` is halo 190897, ellipsoidal region, +starting at z=127, padding 7, levels 7→11, overlap 4, region enclosing 4·R_vir(z=0). + +Lagrangian region files are written to `/ics/lagr/HNRVIR`, each +alongside a `.head` file holding the region centre and extent. + +## Project structure + +``` +cme/ +├── clusters.toml # Cluster profiles: hostnames, paths, scheduler settings +├── pyproject.toml +├── src/cme/ +│ ├── app.py # Entry point; assembles the tabbed window +│ ├── naming.py # Run-directory and Lagrangian path conventions +│ ├── cosmology.py # Named cosmologies (WMAP1-9, PLANCK) +│ ├── config.py # Loads clusters.toml +│ ├── shell.py # Runs external commands without a shell +│ ├── science/ +│ │ └── candidates.py # Isolation-criteria halo selection +│ ├── writers/ +│ │ ├── music.py # MUSIC .conf generation +│ │ ├── gadget.py # param.txt, Config.sh, ExpansionList, softenings +│ │ └── submit.py # PBS and SLURM submission scripts +│ └── ui/ # One HasTraits tab per module +│ ├── common.py # Shared imports, matplotlib-in-TraitsUI editor +│ ├── home.py install.py candidates.py ics.py +│ └── gadget.py halos.py mergertree.py contamination.py +├── tests/ +└── screenshots/ +``` + +Each tab is a `HasTraits` subclass instantiated by `ApplicationMain` and handed a reference +to it, so tabs share state (the halo sample, the base paths, the active figure) through the +`self.main` back-reference. + +## Library + +Everything outside `cme/ui/` is display-free: plain functions over arrays and scalars, with +no Traits, matplotlib or filesystem dependency. That makes the pipeline scriptable for a +large suite and testable without a cluster. + +```python +from cme import naming, cosmology +from cme.writers import music, gadget, submit +from cme.science.candidates import select, ExclusionZone + +# The run-directory convention, in one place +naming.run_dir_name(halo_id=190897, region="ellipsoid", padding=7, + levelmin=7, levelmax=11, overlap=4, nrvir=4) +# 'H190897_BE_Z127_P7_LN7_LX11_O4_NV4' + +# Isolation criteria: "no halo above mass M within distance R" +select(hosts, mass_range=(7e11, 7e12), hubble=0.6711, zones=[ + ExclusionZone(distance=1.4, mass_multiple=0.5), # half my mass, 1.4 Mpc + ExclusionZone(distance=3.0, mass=7e12), + ExclusionZone(distance=4.0, mass=7e13), +]) + +# Configuration files as strings, so they can be diffed and asserted on +music.resim_conf(cosmo=cosmology.PLANCK, boxlength=100, levelmax=11, ...) +gadget.param_file(cosmo=cosmology.PLANCK, boxsize=100.0, levelmax=11) +submit.slurm_script(job_name="H19EP7L11N4", cores=512, ...) +``` + +## Cluster profiles + +`clusters.toml` describes each machine: which hostnames select it, where home directories +live, and where MUSIC, P-Gadget3 and the project data sit, using `{home}`, `{user}` and +`{master}` placeholders. + +```toml +[clusters.odyssey] +hostnames = ["rclogin13.rc.fas.harvard.edu"] +home_root = "/n/home01" +gadget = "{master}/lib/P-Gadget3" +data = "{home}/caterpillar" +scheduler = "SLURM" +queue = "itc_cluster" +``` + +Resolution order: `$CME_CLUSTERS`, then `~/.config/cme/clusters.toml`, then the copy in the +repository. A profile whose `hostnames` list is empty must be selected by hand on the Home +tab. With no file found at all, a single `local` profile under your home directory is used, +so the application still starts. + +## Testing + +```bash +pip install -e ".[dev]" +python -m pytest tests/ # 171 tests +python -m ruff check . +``` + +The suite covers the display-free library only — the naming conventions, the cosmologies, +the candidate selection, the config-file writers and the cluster-profile loader — because +those are the parts that can run without a display, a cluster or the undistributed +`modules` package. Several tests are pinned regressions for specific defects; they cite the +relevant `AUDIT.md` section in a comment. + +CI additionally runs the doctests, `ruff format --check` over the library, and +`compileall` over the GUI modules on Python 3.9 and 3.12 — those cannot be imported in CI, +but they must at least parse. + +## Known limitations + +- **The GUI cannot be launched from a clean checkout**, because the `modules` I/O package is + not distributed here. This is the single biggest barrier to reuse. +- **The GUI is untested.** 171 tests cover the library; the tabs themselves have no + coverage, since exercising them needs a display and a real parent simulation. Changes to + `cme/ui/` are verified only by inspection and by `compileall`. +- wxPython is legacy. TraitsUI still supports it, but Qt (PySide6) is the practical default + and a port is the obvious next step. +- The Install tab builds FFTW2, HDF5 1.8 and GSL 1.9 from a `lib/installs/` source tree that + is also not in the repository. Use your system package manager instead. +- Two parent-simulation paths remain hardcoded in `cme/ui/ics.py` + (`PARENT_SNAPSHOT`, `PARENT_ICS`); they belong in `clusters.toml`. +- The tabs still share state through a `self.main` back-reference, which makes their + construction order load-bearing. + +## Acknowledgments + +Initial conditions are generated with **MUSIC** — Hahn & Abel (2011), *Multi-scale initial +conditions for cosmological simulations*, MNRAS 415, 2101. Simulations are run with +**P-Gadget3**, descended from GADGET-2 — Springel (2005), MNRAS 364, 1105. Halo finding uses +**Rockstar** — Behroozi, Wechsler & Wu (2013), ApJ 762, 109 — and merger trees use +**consistent-trees** — Behroozi et al. (2013), ApJ 763, 18. + +Developed as part of the Caterpillar Project at MIT/Harvard. + +## Contact + +Brendan Griffen — [contact+cme@grifflabs.dev](mailto:contact+cme@grifflabs.dev) + +## License + +MIT — see [`LICENSE`](LICENSE). diff --git a/__init__.py b/__init__.py deleted file mode 100644 index ee4d993..0000000 --- a/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# coding: utf-8 - -""" A tool for constructing and analysing cosmological simulations. """ - -__author__ = "Brendan Griffen " -__version__ = "0.0.1" diff --git a/clusters.toml b/clusters.toml new file mode 100644 index 0000000..5a0e572 --- /dev/null +++ b/clusters.toml @@ -0,0 +1,88 @@ +# Cluster profiles. +# +# Each [clusters.] section describes one machine: where home directories +# live, and where MUSIC, P-Gadget3 and the project data sit relative to it. +# These were previously an if-chain on platform.node() inside the Home tab, with +# absolute paths, usernames and email addresses baked into the source, so nobody +# but the original author could use the tool without editing it. +# +# Placeholders expanded in every path value: +# {home} the profile's home_root, or the current user's home directory +# {user} the profile's `username`, or the login name +# {master} {home}/{user} +# +# `hostnames` lists the platform.node() values that select this profile +# automatically. A profile with no hostnames must be selected by hand. +# +# No profile here sets `username`, so {user} is your login name. If a site gives +# you a different login than the one you use locally, set it in a personal +# override rather than here -- this file is shared: +# +# ~/.config/cme/clusters.toml +# +# [clusters.barrine] +# username = "your-site-login" +# +# The lookup order is $CME_CLUSTERS, then ~/.config/cme/clusters.toml, then this +# file. + +default = "local" + +[clusters.local] +# Fallback when the hostname matches nothing. Everything under the user's home. +hostnames = [] +gadget = "{master}/lib/P-Gadget3" +music = "{master}/lib/music" +data = "{master}/projects/caterpillar/data" +parent = "{master}/projects/caterpillar/parent" + +[clusters.bigbang] +hostnames = ["bigbang.mit.edu"] +home_root = "/bigbang/data" +gadget = "{master}/lib/P-Gadget3" +music = "{master}/lib/music" +data = "{home}/AnnaGroup/caterpillar" +parent = "{home}/AnnaGroup/caterpillar/parent" +scheduler = "PBS" +queue = "default" +# Torque needs its OpenMPI environment sourced before mpirun. +extra_lines = "source /opt/torque/etc/openmpi-setup.sh" +extra_flags = "#PBS -m ae" + +[clusters.odyssey] +hostnames = ["rclogin13.rc.fas.harvard.edu"] +home_root = "/n/home01" +gadget = "{master}/lib/P-Gadget3" +music = "{master}/lib/music" +data = "{home}/caterpillar" +parent = "{home}/caterpillar/parent" +scheduler = "SLURM" +queue = "itc_cluster" + +[clusters.spacebase] +hostnames = ["spacebase"] +home_root = "/spacebase/data" +gadget = "{master}/lib/P-Gadget3" +music = "{master}/lib/music" +data = "{home}/AnnaGroup/caterpillar" +parent = "{home}/AnnaGroup/caterpillar/parent" +scheduler = "PBS" +queue = "default" + +[clusters.antares] +hostnames = ["antares"] +gadget = "{master}/lib/P-Gadget3" +music = "{master}/lib/music" +data = "{master}/projects/caterpillar/data" +parent = "{master}/projects/caterpillar/parent" +scheduler = "PBS" +queue = "default" + +[clusters.barrine] +hostnames = ["barrine"] +gadget = "{master}/lib/P-Gadget3" +music = "{master}/lib/music" +data = "{master}/projects/caterpillar/data" +parent = "{master}/projects/caterpillar/parent" +scheduler = "PBS" +queue = "default" diff --git a/gadgetrun.py b/gadgetrun.py deleted file mode 100644 index 78a7621..0000000 --- a/gadgetrun.py +++ /dev/null @@ -1,936 +0,0 @@ -from Common import * -import modules.mergertrees.MTCatalogue as MT - -class GadgetRun(HasTraits): - #clusteropt = List() - - PBSncores = Range(0,64,8) - PBSnnodes = Range(0,10,8) - username = Str("bgriffen") - PBSemail = Str("bgriffen@space.mit.edu") - PBSjobname = Str() - PBSqueue = Str("default") - outfile = Str("OUTPUT") - errorfile = Str("ERROR") - PBSextraflags = Str("#PBS -m ae ") - PBSextralines = Str("source /opt/torque/etc/openmpi-setup.sh") - PBSexecute = Str() - compilegadopt = Bool(False) - InitCondFile = Str() - OutputDir = Str() - EnergyFile = Str() - InfoFile = Str() - TimingsFile = Str() - CpuFile = Str() - RestartFile = Str() - SnapshotFileBase = Str() - OutputListFilename = Str() - TimebinFile = Str() - ICFormat = Int() - SnapFormat = Int() - TimeLimitCPU = Int() - CpuTimeBetRestartFile = Int() - ResubmitOn = Int() - ResubmitCommand = Str() - MaxMemSize = Int() - PartAllocFactor = Float() - BufferSize = Int() - TimeBegin = Float() - TimeMax = Float() - ComovingIntegrationOn = Int() - PeriodicBoundariesOn = Int() - CoolingOn = Int() - StarformationOn = Int() - Omega0 = Float() - OmegaLambda = Float() - OmegaBaryon = Float() - HubbleParam = Float() - BoxSize = Float() - OutputListOn = Int() - TimeBetSnapshot = Float() - TimeOfFirstSnapshot = Float() - TimeBetStatistics = Float() - NumFilesPerSnapshot = Int() - NumFilesWrittenInParallel = Int() - TypeOfTimestepCriterion = Int() - ErrTolIntAccuracy = Float() - CourantFac = Float() - MaxRMSDisplacementFac = Float() - MaxSizeTimestep = Float() - MinSizeTimestep = Float() - InitGasTemp = Float() - MinGasTemp = Float() - TypeOfOpeningCriterion = Int() - ErrTolTheta = Float() - ErrTolForceAcc = Float() - TreeDomainUpdateFrequency = Float() - DesNumNgb = Int() - MaxNumNgbDeviation = Int() - UnitLength_in_cm = Float() - UnitMass_in_g = Float() - UnitVelocity_in_cm_per_s = Float() - GravityConstantInternal = Int() - MinGasHsmlFractional = Float() - SofteningGas = Float() - SofteningHalo = Float() - SofteningDisk = Float() - SofteningBulge = Float() - SofteningStars = Float() - SofteningBndry = Float() - SofteningGasMaxPhys = Float() - SofteningHaloMaxPhys = Float() - SofteningDiskMaxPhys = Float() - SofteningBulgeMaxPhys = Float() - SofteningStarsMaxPhys = Float() - SofteningBndryMaxPhys = Float() - ArtBulkViscConst = Float() - levelmaxuse = Int(12) - ErrTolThetaSubfind = Float - DesLinkNgb = Int - ExpansionListArr = Array - - PMGRID = Int(512) - ENABLE_SUBFIND = Bool(False) - FOF_SECONDARY_LINK_TYPES = Str() - - gadpath = Directory - checkexistence_button = Button("Check Existence") - createconfig_button = Button("Make and distribute gadget!") - padding = List(editor = CheckListEditor(values = ['5','6','7','8','9','10'],cols=6) ) - lmin = Enum(['7','8','9','10','11','12','13','14','15']) - lmax = Enum(['7','8','9','10','11','12','13','14','15']) - overlap = List(editor = CheckListEditor(values = ['1','2','3','4','5','6'],cols=6) ) - nrvir = List(editor = CheckListEditor(values = ['1','2','3','4','5','6','7','8','9'],cols=11)) - cosmologylist = List( editor = CheckListEditor(values = ['WMAP1','WMAP3','WMAP5','WMAP7','WMAP9','PLANCK'],cols = 6) ) - boxtype = List( editor = CheckListEditor(values = ['ellipsoid','box'],cols=2)) - haloid = Int(190897) - haloidlist = List(Int) - zinit = Range(0,127,127) - redshifti = Float() - expfacti = Float() - nintervals = Enum(['32','64','128','258','512','1024']) - snapshotlist = Str() - alloutputopt = Bool(True) - PBSstring = Str() - vizexpz_button = Button("Inspect/Update List") - subscript_button = Button("Run Script") - submitjob = Bool(False) - - SLURMcores = Range(64,512,64) - SLURMtime = Int(5000) - SLURMmemory = Int(3500) - SLURMqueue = Enum(['itc_cluster','general','serial_requeue']) - emailaddress = Str('brendan.f.griffen@gmail.com') - subtype = Enum(['SLURM','PBS']) - SLURMexecute = Str() - - view = View(Group(Group(Group( - Item('gadpath',label='Base Path'), - Item(name='haloid',label='Halo ID'), - Item(name='nrvir',label='Nvir',style='custom'), - HGroup(Item(name='boxtype',label='Box Type',style='custom'),Item(name='lmin',label='LMIN'),Item(name='lmax',label='LMAX'),Item(name='zinit',label='zinit')), - Item(name='cosmologylist',label='Cosmology',style='custom'), - Item(name='padding',label='Padding',style='custom'), - Item(name='overlap',label='Overlap',style='custom')), - Group(Item('checkexistence_button',show_label=False)), - Group(Item(name='haloidlist',show_label=False,style='readonly',editor=ListEditor(style='readonly',columns=6,rows=4)),label='Halo Sample',show_border=True)),label='Existence'), - - Group( - Item('InitCondFile',label='InitCondFile'), - Item('OutputDir',label='OutputDir'), - Item('EnergyFile',label='EnergyFile'), - Item('InfoFile' ,label='InfoFile '), - Item('TimingsFile',label='TimingsFile'), - Item('CpuFile',label='CpuFile'), - Item('RestartFile',label='RestartFile'), - Item('SnapshotFileBase',label='SnapshotFileBase'), - Item('OutputListFilename',label='OutputListFilename'), - Item('TimebinFile',label='TimebinFile'), - Item('ICFormat',label='ICFormat'), - Item('SnapFormat',label='SnapFormat'), - Item('TimeLimitCPU',label='TimeLimitCPU'), - Item('CpuTimeBetRestartFile',label='CpuTimeBetRestartFile'), - Item('ResubmitOn',label='ResubmitOn'), - Item('ResubmitCommand',label='ResubmitCommand'), - Item('MaxMemSize',label='MaxMemSize'), - Item('PartAllocFactor',label='PartAllocFactor'), - Item('BufferSize',label='BufferSize'), - Item('TimeBegin',label='TimeBegin'), - Item('TimeMax',label='TimeMax'),label='Format'), - - Group(Item('ComovingIntegrationOn',label='ComovingIntegrationOn'), - Item('PeriodicBoundariesOn',label='PeriodicBoundariesOn'), - Item('CoolingOn',label='CoolingOn'), - Item('StarformationOn',label='StarformationOn'), - Item('Omega0',label='Omega0'), - Item('OmegaLambda',label='OmegaLambda'), - Item('OmegaBaryon',label='OmegaBaryon'), - Item('HubbleParam',label='HubbleParam'), - Item('BoxSize',label='BoxSize'), - Item('OutputListOn',label='OutputListOn'), - Item('TimeBetSnapshot',label='TimeBetSnapshot'), - Item('TimeOfFirstSnapshot',label='TimeOfFirstSnapshot'), - Item('TimeBetStatistics',label='TimeBetStatistics'), - Item('NumFilesPerSnapshot',label='NumFilesPerSnapshot'), - Item('NumFilesWrittenInParallel',label='NumFilesWrittenInParallel'), - Item('TypeOfTimestepCriterion',label='TypeOfTimestepCriterion'), - Item('ErrTolIntAccuracy',label='ErrTolIntAccuracy'), - Item('CourantFac',label='CourantFac'), - Item('MaxRMSDisplacementFac',label='MaxRMSDisplacementFac'), - Item('MaxSizeTimestep',label='MaxSizeTimestep'), - Item('MinSizeTimestep',label='MinSizeTimestep'), - Item('InitGasTemp',label='InitGasTemp'), - Item('MinGasTemp',label='MinGasTemp'),label='Cosmology & Timestep'), - - Group(Item('TypeOfOpeningCriterion',label='TypeOfOpeningCriterion'), - HGroup(Item('ErrTolTheta',label='ErrTolTheta'), - Item('ErrTolForceAcc',label='ErrTolForceAcc')), - Item('TreeDomainUpdateFrequency',label='TreeDomainUpdateFrequency'), - Item('DesNumNgb',label='DesNumNgb'), - Item('MaxNumNgbDeviation',label='MaxNumNgbDeviation'), - Item('UnitLength_in_cm',label='UnitLength_in_cm'), - Item('UnitMass_in_g',label='UnitMass_in_g'), - Item('UnitVelocity_in_cm_per_s',label='UnitVelocity_in_cm_per_s'), - Item('GravityConstantInternal',label='GravityConstantInternal'), - - Item('MinGasHsmlFractional',label='MinGasHsmlFractional'), - Item('SofteningGas',label='SofteningGas'), - Item('SofteningHalo',label='SofteningHalo'), - Item('SofteningDisk',label='SofteningDisk'), - Item('SofteningBulge',label='SofteningBulge'), - Item('SofteningStars',label='SofteningStars'), - Item('SofteningBndry',label='SofteningBndry'), - Item('SofteningGasMaxPhys',label='SofteningGasMaxPhys'), - Item('SofteningHaloMaxPhys',label='SofteningHaloMaxPhys'), - Item('SofteningDiskMaxPhys',label='SofteningDiskMaxPhys'), - Item('SofteningBulgeMaxPhys',label='SofteningBulgeMaxPhys'), - Item('SofteningStarsMaxPhys',label='SofteningStarsMaxPhys'), - Item('SofteningBndryMaxPhys',label='SofteningBndryMaxPhys'), - Item('ArtBulkViscConst' ,label='ArtBulkViscConst '),label='Softening'), - - Group(HGroup(Item('redshifti',label='Starting Output Redshift'),Item('expfacti',label='Expansion Factor')), - Item('nintervals',label='Number Of Outputs',style='custom'), - HGroup(Item('alloutputopt',label='Output Every Snapshot?'),Group(Item('snapshotlist',label='Only Specific Snapshots (csv)',springy=True),springy=True,enabled_when='alloutputopt == False')), - Group(Item('vizexpz_button',show_label=False),enabled_when='haloid in haloidlist'), - Heading('ExpansionList Output (last two columns)'), - Item('ExpansionListArr',show_label = False, - editor= ArrayViewEditor(titles = [ 'snapshot','redshift','exp. fact.', ' I/O' ], - format = '%2.5e', - show_index = False - )),label='Exp.'), - - Group(HGroup(Item('PMGRID',label='PMGRID'), - Item('ENABLE_SUBFIND',label='Subfind'), - Group(Item('FOF_SECONDARY_LINK_TYPES',label='FOF_SECONDARY_LINK_TYPES'),enabled_when='ENABLE_SUBFIND==True',springy=True)), - Item('_'), - Item('clusteropt',label='Cluster',style='readonly'), - Item('subtype',label='Submission Type'), - Item('emailaddress',label='Email'), - Item('outfile',label='Output File'), - Item('errorfile',label='Error File'), - Item('username',label='Username'), - Group( - Item('PBSncores',label='# Cores'), - Item('PBSnnodes',label='# Nodes'), - Item('PBSqueue',label='Queue'), - Item('PBSextraflags',label='Extra Flag'), - Item('PBSextralines',label='Extra Lines'), - Item('PBSexecute',label='Execute Line'),show_border=True,enabled_when="subtype=='PBS'"), - - Group( - Item('SLURMcores',label='# Cores'), - Item('SLURMtime',label='Runtime'), - Item('SLURMmemory',label='Memory'), - Item('SLURMqueue',label='Queue'), - Item('SLURMexecute',label='Execute Line'),show_border=True,enabled_when="subtype=='SLURM'"), - - HGroup(Item('compilegadopt',label='(Re-)Compile Gadget?'),Item('submitjob',label='Submit Job?'),Item('subscript_button',show_label=False)), - label='Write & Submit Job') - ) - - def _subscript_button_fired(self): - - f1 = open("rungadget.sh",'w') - f1.write("#!/bin/bash \n") - - if self.subtype == "PBS": - gadgetfilepath = "/bigbang/data/bgriffen/lib/" - - f1.write("cd " + gadgetfilepath + "P-Gadget3\n") - f1.write("ssh antares <<'ENDSSH' \n") - - if self.subtype == "SLURM": - gadgetfilepath = "/n/home01/bgriffen/data/lib/" - f1.write("unloadmods\n") - f1.write("loadgadget\n") - f1.write("cd " + gadgetfilepath + "P-Gadget3\n") - - f1.write("make clean\n") - f1.write("make -j 8\n") - - for boxtypei in self.boxtype: - for nrviri in self.nrvir: - for paddingi in self.padding: - for overlapi in self.overlap: - foldername = 'H' + str(self.haloid) + \ - '_B' + str(boxtypei.upper())[0] + \ - '_Z' + str(self.zinit) + \ - '_P' + str(paddingi) + \ - '_LN' + str(self.lmin) + \ - '_LX' + str(self.lmax) + \ - '_O' + str(overlapi) + \ - '_NV' + str(nrviri) - jobname = 'H' + str(self.haloid)[0:2] + \ - str(boxtypei.upper())[0] + \ - 'P' + str(paddingi) + \ - 'L' + str(self.lmax) + \ - 'N' + str(nrviri) - - filepath = self.gadpath + 'halos/H' + str(self.haloid) + '/' + foldername - if not os.path.exists(filepath): - print "PATH NOT FOUND:",filepath - else: - print "PATH FOUND:",filepath - self.levelmaxuse = int(self.lmax) - self.Omega0,self.OmegaLambda,self.OmegaBaryon,self.HubbleParam,sigma8,nspec = cosmoconstant(self.cosmologylist[0]) - ext = self.gadpath + 'halos/H' + str(self.haloid) + '/' + foldername - if self.ENABLE_SUBFIND == True: - self.makeparam('param.txt',ext,includesub=False) - self.makeparam('param_sub.txt',ext,includesub=True) - mving1 = "mv param.txt " + filepath - mving2 = "mv param_sub.txt " + filepath - subprocess.call(';'.join([mving1,mving2]), shell=True) - else: - self.makeparam('param.txt',ext,includesub=False) - mving = "mv param.txt " + filepath - subprocess.call([mving], shell=True) - - mkdirs = "mkdir -p " + filepath + "/outputs" - subprocess.call([mkdirs], shell=True) - - if self.ENABLE_SUBFIND == True: - f = open(filepath + "/runscript_sub",'w') - else: - f = open(filepath + "/runscript",'w') - #f = open(filepath + "/runscript",'w') - - - if self.subtype == "SLURM": - f.write('#!/bin/bash\n') - f.write('#SBATCH -n ' + self.SLURMcores + '\n') - f.write('#SBATCH -o ' + jobname + '.o%j\n') - f.write('#SBATCH -e ' + jobname + '.e%j\n') - f.write('#SBATCH -t ' + self.SLURMtime + '\n') - f.write('#SBATCH -p ' + self.SLURMqueue + '\n') - f.write('#SBATCH --mem-per-cpu=' + self.SLURMmemory + '\n') - f.write('#SBATCH --mail-user=' + self.emailaddress + '\n') - f.write('#SBATCH -J '+ jobname + '\n') - f.write('#SBATCH --mail-type=begin\n') - f.write('#SBATCH --mail-type=end\n') - f.write("\n") - f.write("cd " + filepath + "\n") - f.write("\n") - self.SLURMexecute = 'mpirun -np ' + str(self.PBSncores) + './P-Gadget3 param.txt 1>OUTPUT 2>ERROR\n' - f.write(self.SLURMexecute +'\n') - f.close() - - if self.subtype == "PBS": - f.write("#!/bin/csh" + "\n") - f.write("#PBS -k eo\n") - f.write("#PBS -l nodes=" + str(self.PBSnnodes) + ":ppn=" + str(self.PBSncores) + "\n") - f.write("#PBS -N " + jobname + "\n") - f.write("#PBS -M " + self.emailaddress + "\n") - f.write("#PBS -o " + filepath + "/" + jobname + ".out\n") - f.write("#PBS -e " + filepath + "/" + jobname + ".err\n") - #f.write("#PBS -u " + self.username + "\n") - f.write("#PBS -q " + self.PBSqueue + "\n") - f.write(self.PBSextraflags + "\n") - f.write("\n") - f.write(self.PBSextralines + "\n") - f.write("\n") - f.write("cd " + filepath + "\n") - f.write("\n") - f.write(self.PBSexecute) - f.close() - - if self.compilegadopt == True: - if self.ENABLE_SUBFIND == True: - self.constructconfigsh(filepath + "/Config_sub.sh") - command5 = "tail -n+96 " + gadgetfilepath + "P-Gadget3/Config.sh > " + filepath + "/bottomConfig" - command6 = "cat " + filepath + "/bottomConfig >> " + filepath + "/Config_sub.sh" - command7 = "cp " + filepath + "/Config_sub.sh " + gadgetfilepath + "P-Gadget3/Config.sh" - subprocess.call(';'.join([command5,command6,command7]), shell=True) - else: - self.constructconfigsh(filepath + "/Config.sh") - command5 = "tail -n+96 " + gadgetfilepath + "/P-Gadget3/Config.sh > " + filepath + "/bottomConfig" - command6 = "cat " + filepath + "/bottomConfig >> " + filepath + "/Config.sh" - command7 = "cp " + filepath + "/Config.sh " + gadgetfilepath + "P-Gadget3/Config.sh" - subprocess.call(';'.join([command5,command6,command7]), shell=True) - rmconfig = "rm " + filepath + "/bottomConfig" - subprocess.call([rmconfig], shell=True) - #try: - # with open("/bigbang/data/bgriffen/lib/P-Gadget3/P-Gadget3"): pass - f1.write("cd " + str(filepath) + "\n") - f1.write("rm " + str(filepath) + "/*.e*\n") - f1.write("rm " + str(filepath) + "/*.o*\n") - #f1.write("rm " + str(filepath) + "/ExpansionList_64\n") - - if self.ENABLE_SUBFIND == True: - f1.write("\n") - f1.write("rm P-Gadget3_sub\n") - f1.write("rm P-Gadget3_Sub\n") - f1.write("rm ERRORsub\n") - f1.write("rm OUTPUTsub\n") - f1.write("cp " + gadgetfilepath + "P-Gadget3/P-Gadget3 " + filepath + "/P-Gadget3_sub\n") - if self.submitjob == True: - f1.write("qsub runscript_sub \n") - else: - f1.write("\n") - f1.write("rm P-Gadget3\n") - f1.write("rm ERROR\n") - f1.write("rm OUTPUT\n") - f1.write("cp " + gadgetfilepath + "P-Gadget3/P-Gadget3 " + filepath + "/P-Gadget3\n") - - if self.submitjob == True: - if self.subtype == "PBS": - f1.write("qsub runscript \n") - elif self.subtype == "SLURM": - f1.write("sbatch runscript \n") - - if self.subtype == "PBS": - f1.write("logout \n") - - f1.close() - - command = "bash rungadget.sh" - subprocess.call(';'.join([command]), shell=True) - - #command = "rm " + str(filepath) + "/rungadget.sh" - #subprocess.call(';'.join([command]), shell=True) - - print "SUBMITTED JOBS!" - - def _ENABLE_SUBFIND_changed(self): - if self.ENABLE_SUBFIND == True: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3_sub ./param_sub.txt 3 63 1>" + self.outfile + "sub 2>" + self.errorfile + "sub" - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3_sub ./param_sub.txt 3 63 1>" + self.outfile + "sub 2>" + self.errorfile + "sub" - else: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - - def _PBSncores_changed(self): - if self.ENABLE_SUBFIND == True: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3_sub ./param_sub.txt 3 63 1>" + self.outfile + "sub 2>" + self.errorfile + "sub" - else: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - - def _PBSnnodes_changed(self): - if self.ENABLE_SUBFIND == True: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3_sub ./param_sub.txt 3 63 1>" + self.outfile + "sub 2>" + self.errorfile + "sub" - else: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - - def _SLURMcores_changed(self): - if self.ENABLE_SUBFIND == True: - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - else: - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - - def _errorfile_changed(self): - if self.ENABLE_SUBFIND == True: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3_sub ./param_sub.txt 3 63 1>" + self.outfile + "sub 2>" + self.errorfile + "sub" - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3_sub ./param_sub.txt 3 63 1>" + self.outfile + "sub 2>" + self.errorfile + "sub" - else: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - - def _outfile_changed(self): - if self.ENABLE_SUBFIND == True: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3_sub ./param_sub.txt 3 63 1>" + self.outfile + "sub 2>" + self.errorfile + "sub" - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3_sub ./param_sub.txt 3 63 1>" + self.outfile + "sub 2>" + self.errorfile + "sub" - else: - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - - - - def _vizexpz_button_fired(self): - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - ax = self.main.display.axes[0] - - snapshots = range(0,int(self.nintervals)) - expfact = np.linspace(self.expfacti,1,int(self.nintervals)) - - ax.set_xlabel('Snapshot') - ax.set_ylabel('Expansion Factor') - redall = np.float64(1./expfact - 1) - - if self.alloutputopt == True: - self.ExpansionListArr = np.zeros(shape=(len(snapshots),4)) - self.ExpansionListArr[:,0] = snapshots - self.ExpansionListArr[:,1] = redall - self.ExpansionListArr[:,2] = expfact - self.ExpansionListArr[:,3] = np.array(len(expfact)*[1]) - self.display_points = ax.plot(snapshots,expfact,'bo') - - else: - listin = str(self.snapshotlist) - snapshotin = np.array(listin.split(',')) - listcomp = np.array(self.ExpansionListArr[:,0]) - self.ExpansionListArr[:,3] = np.array(len(expfact)*[0]) - for i in xrange(0,len(listcomp)): - for j in xrange(0,len(snapshotin)): - if int(listcomp[i]) == int(snapshotin[j]): - self.ExpansionListArr[i,3] = 1 - - self.display_points = ax.plot(snapshots,expfact,'b-') - wx.CallAfter(self.main.display.canvas.draw) - - # listin = str(self.specificredshift) - # redshiftlist =listin.split(',') - # io1 = [] - # io2 = [] - # io3 = [] - # io4 = [] - # for redshift in redshiftlist: - # redshift = float(redshift) - # exptmp = np.float64(1./(1+redshift)) - # foundexp = min(range(len(expfact)), key=lambda i: abs(np.float64(expfact[i])-exptmp)) - # ax.plot(snapshots[foundexp],expfact[foundexp],'bo') - # io1.append(snapshots[foundexp]) - # io2.append(float(redshift)) - # io3.append(expfact[foundexp]) - # io4.append(1) - - #self.ExpansionListArr = [] - # self.ExpansionListArr = np.zeros(shape=(len(io1),4)) - # self.ExpansionListArr[:,0] = io1 - # self.ExpansionListArr[:,1] = io2 - # self.ExpansionListArr[:,2] = io3 - # self.ExpansionListArr[:,3] = io4 - - - def _expfacti_changed(self): - self.redshifti = 1./self.expfacti - 1 - - def _redshifti_changed(self): - self.expfacti = 1./(1 + self.redshifti) - - def _BoxSize_changed(self): - self.SofteningGas = 0.0 - self.SofteningHalo = self.BoxSize/2**float(self.lmax)/40. - self.SofteningDisk = 2*self.SofteningHalo - self.SofteningBulge = 2*self.SofteningDisk - self.SofteningStars = 2*self.SofteningBulge - self.SofteningBndry = 2*self.SofteningStars - self.SofteningGasMaxPhys = 0.0 - self.SofteningHaloMaxPhys = self.BoxSize/2**float(self.lmax)/40. - self.SofteningDiskMaxPhys = 2*self.SofteningHaloMaxPhys - self.SofteningBulgeMaxPhys = 2*self.SofteningDiskMaxPhys - self.SofteningStarsMaxPhys = 2*self.SofteningBulgeMaxPhys - self.SofteningBndryMaxPhys = 2*self.SofteningStarsMaxPhys - - def _lmax_changed(self): - if int(self.lmax) <= 11: - self.PMGRID = 256 - elif 12 <= int(self.lmax) <= 13: - self.PMGRID = 512 - elif int(self.lmax) > 13: - self.PMGRID = 1024 - - self.SofteningGas = 0.0 - self.SofteningHalo = self.BoxSize/2**float(self.lmax)/40. - self.SofteningDisk = 2*self.SofteningHalo - self.SofteningBulge = 2*self.SofteningDisk - self.SofteningStars = 2*self.SofteningBulge - self.SofteningBndry = 2*self.SofteningStars - self.SofteningGasMaxPhys = 0.0 - self.SofteningHaloMaxPhys = self.BoxSize/2**float(self.lmax)/40. - self.SofteningDiskMaxPhys = 2*self.SofteningHaloMaxPhys - self.SofteningBulgeMaxPhys = 2*self.SofteningDiskMaxPhys - self.SofteningStarsMaxPhys = 2*self.SofteningBulgeMaxPhys - self.SofteningBndryMaxPhys = 2*self.SofteningStarsMaxPhys - - def constructconfigsh(self,filename): - f = open(filename,'w') - f.write("#!/bin/bash\n") - f.write("##################################################\n") - f.write("# Enable/Disable compile-time options as needed #\n") - f.write("##################################################\n") - f.write("#--------------------------------------- Basic operation mode of code\n") - f.write("PERIODIC\n") - f.write("#COOLING\n") - f.write("#SFR\n") - f.write("#SINKS\n") - f.write("UNEQUALSOFTENINGS\n") - f.write("#NUM_THREADS=4 # Now OpenMP works the same, so don't compile with OpenMP *and* PTHREADS !\n") - f.write("#--------------------------------------- Kernel Options\n") - f.write("#QUINTIC_KERNEL # Implementation of the Morris 1996 quintic spline kernel, requires (3/2)^3 more neighbours !\n") - f.write("#TWODIMS # Switch for 2D test problems\n") - f.write("#ONEDIM # Switch for 1D test problems\n") - f.write("#--------------------------------------- TreePM Options\n") - f.write("PMGRID=" + str(self.PMGRID) + "\n") - f.write("GRIDBOOST=2\n") - f.write("##ASMTH=1.25\n") - f.write("#RCUT=5.25\n") - f.write("PLACEHIGHRESREGION=2\n") - f.write("ENLARGEREGION=1.2\n") - f.write("#--------------------------------------- Multi-Domain and Top-Level Tree options\n") - f.write("MULTIPLEDOMAINS=8\n") - f.write("#TOPNODEFACTOR=3.0\n") - f.write("#KD_HMAX_ESTIMATE # Alternative way to update HMAX within Tree nodes\n") - f.write("#--------------------------------------- Things that are always recommended\n") - f.write("PEANOHILBERT\n") - f.write("WALLCLOCK\n") - f.write("MYSORT\n") - f.write("#AUTO_SWAP_ENDIAN_READIC # Enables automatic ENDIAN swapping for reading ICs\n") - f.write("#WRITE_KEY_FILES # Enables writing key index files\n") - f.write("#WRITE_INFO_BLOCK # Enables writing the INFO block\n") - f.write("#PERMUTATAION_OPTIMIZATION\n") - f.write("#PROCESS_TIMES_OF_OUTPUTLIST # Chooses the outputtime closest to any global step\n") - f.write("#SYNCRONIZ_OUTPUT # Writes output only at global time steps\n") - f.write("#---------------------------------------- Single/Double Precision\n") - f.write("DOUBLEPRECISION\n") - f.write("DOUBLEPRECISION_FFTW\n") - f.write("#OUTPUT_IN_DOUBLEPRECISION # snapshot files will be written in double precision\n") - f.write("#INPUT_IN_DOUBLEPRECISION\n") - f.write("#---------------------------------------- Invariance Test\n") - f.write("#INVARIANCETEST\n") - f.write("#INVARIANCETEST_SIZE1=2\n") - f.write("#INVARIANCETEST_SIZE2=6\n") - f.write("#FLTROUNDOFFREDUCTION # enables (expensive!) `double-double' round-off reduction in particle sums\n") - f.write("#SOFTDOUBLEDOUBLE # needs to be set if a C++ software implementation of 128bit double-double precision should be used\n") - f.write("#---------------------------------------- On the fly FOF groupfinder\n") - - if self.ENABLE_SUBFIND == True: - f.write("FOF\n") - f.write("FOF_PRIMARY_LINK_TYPES=2 # 2^type for the primary dark matter type\n") - f.write("FOF_SECONDARY_LINK_TYPES=" + str(self.FOF_SECONDARY_LINK_TYPES) + " # 2^type for the types linked to nearest primaries\n") - else: - f.write("#FOF\n") - f.write("#FOF_PRIMARY_LINK_TYPES=2 # 2^type for the primary dark matter type\n") - f.write("#FOF_SECONDARY_LINK_TYPES=" + str(self.FOF_SECONDARY_LINK_TYPES) + " # 2^type for the types linked to nearest primaries\n") - - f.write("#FOF_GROUP_MIN_LEN=32 # default is 32\n") - - if self.ENABLE_SUBFIND == True: - f.write("SUBFIND\n") - else: - f.write("#SUBFIND\n") - - f.write("#DENSITY_SPLIT_BY_TYPE=1+2+16+32 # 2^type for whch the densities should be calculated seperately\n") - f.write("#MAX_NGB_CHECK=3 # Max numbers of neighbours for sattlepoint detection (default = 2)\n") - f.write("#SAVE_MASS_TAB # Saves the an additional array with the masses of the different components\n") - f.write("#SUBFINDSAVE_PARTICLELISTS # Saves also phase-space and type variables parallel to IDs\n") - f.write("#SO_VEL_DISPERSIONS # computes velocity dispersions for as part of FOF SO-properties\n") - f.write("#ORDER_SNAPSHOTS_BY_ID\n") - f.write("#SAVE_HSML_IN_IC_ORDER # will store the hsml-values in the order of the particles in the IC file\n") - f.write("#ONLY_PRODUCE_HSML_FILES # only carries out density estimate\n") - f.write("#KEEP_HSML_AS_GUESS # keep using hsml for gas particles in subfind_density\n") - f.write("#LINKLENGTH=0.16 # Linkinglength for FoF (default=0.2)\n") - f.write("#NO_GAS_CLOUDS # Do not accept pure gaseous substructures\n") - f.write("#WRITE_SUB_IN_SNAP_FORMAT # Save subfind results in snap format\n") - f.write("#LT_ADD_GAL_TO_SUB=12 # Adds optical luminosities in 6 bands to subhalos\n") - f.write("#DUSTATT=11 # Includes dust attenuation into the luminosity calculation (using 11 radial bins)\n") - f.write("#OBSERVER_FRAME # If defined, use CB07 Observer Frame Luminosities, otherwise CB07 Rest Frame Luminosities\n") - f.write("#SO_BAR_INFO # Adds temperature, Lx, bfrac, etc to Groups\n") - f.write("#FSUBFINDCOUNT_BIG_HALOS=1e4 # Adds extra blocks for Halos with M_TopHat > SUBFIND_COUNT_BIG_HALOS\n") - f.write("#KD_CHOOSE_PSUBFIND_LIMIT # Increases the limit for the parallel subfind to the maximum possible\n") - f.write("#KD_ALTERNATIVE_GROUP_SORT # Alternative way to sort the Groups/SubGroupe before writing\n") - f.write("#KD_CHOOSE_LINKING_LENGTH # Special way to estimate the linking length\n") - f.write("#SUBFINDREAD_FOF\n") - f.write("#SUBFINDCOLLECTIVE_STAGE1\n") - f.write("#SUBFINDCOLLECTIVE_STAGE2\n") - f.write("#SUBFINDALTERNATIVE_COLLECTIVE\n") - f.write("#SUBFINDRESHUFFLE_CATALOGUE\n") - f.write("#SUBFINDRESHUFFLE_CATALOGUE_WITH_VORONOI\n") - f.write("#SUBFINDRESHUFFLE_AND_POTENTIAL #needs -DSUBFIND_RESHUFFLE_CATALOGUE and COMPUTE_POTENTIAL_ENERGY\n") - f.write("#SUBFINDDENSITY_AND_POTENTIAL #only calculated density and potential and write them into snapshot\n") - f.close() - - def makeparam(self,filename,ext,includesub): - snapshots = range(0,int(self.nintervals)) - expfact = np.linspace(self.expfacti,1,int(self.nintervals)) - expfilename = ext + '/' + self.OutputListFilename - f = open(expfilename,'w') - - for i in xrange(0,len(self.ExpansionListArr[:,0])): - f.write(str(np.float64(self.ExpansionListArr[i,2])) + ' ' + str(int(self.ExpansionListArr[i,3])) + '\n') - - f.close() - - f = open(filename,'w') - f.write('%---- Relevant files' + '\n') - f.write('InitCondFile ' + str(self.InitCondFile) + '\n') - f.write('OutputDir ' + str(self.OutputDir) + '\n') - f.write('EnergyFile ' + str(self.EnergyFile) + '\n') - f.write('InfoFile ' + str(self.InfoFile) + '\n') - f.write('TimingsFile ' + str(self.TimingsFile) + '\n') - f.write('CpuFile ' + str(self.CpuFile) + '\n') - f.write('RestartFile ' + str(self.RestartFile) + '\n') - f.write('SnapshotFileBase ' + str(self.SnapshotFileBase) + '\n') - f.write('OutputListFilename ' + str(self.OutputListFilename) + '\n') - f.write('TimebinFile ' + str(self.TimebinFile) + '\n') - f.write('\n') - f.write('%---- File formats' + '\n') - f.write('ICFormat ' + str(self.ICFormat) + '\n') - f.write('SnapFormat ' + str(self.SnapFormat) + '\n') - f.write('\n') - f.write('%---- CPU-time limits' + '\n') - f.write('TimeLimitCPU ' + str(self.TimeLimitCPU) + '\n') - f.write('CpuTimeBetRestartFile ' + str(self.CpuTimeBetRestartFile) + '\n') - f.write('ResubmitOn ' + str(self.ResubmitOn) + '\n') - f.write('ResubmitCommand ' + str(self.ResubmitCommand) + '\n') - f.write('\n') - f.write('%----- Memory alloction' + '\n') - f.write('MaxMemSize ' + str(self.MaxMemSize) + '\n') - f.write('PartAllocFactor ' + str(self.PartAllocFactor) + '\n') - f.write('BufferSize ' + str(self.BufferSize) + '\n') - f.write('\n') - f.write('%---- Caracteristics of run' + '\n') - f.write('TimeBegin ' + str(self.TimeBegin) + '\n') - f.write('TimeMax ' + str(self.TimeMax) + '\n') - f.write('\n') - f.write('%---- Basic code options that set the type of simulation' + '\n') - f.write('ComovingIntegrationOn ' + str(self.ComovingIntegrationOn) + '\n') - f.write('PeriodicBoundariesOn ' + str(self.PeriodicBoundariesOn) + '\n') - f.write('CoolingOn ' + str(self.CoolingOn) + '\n') - f.write('StarformationOn ' + str(self.StarformationOn) + '\n') - f.write('\n') - f.write('%---- Cosmological parameters' + '\n') - f.write('Omega0 ' + str(self.Omega0) + '\n') - f.write('OmegaLambda ' + str(self.OmegaLambda) + '\n') - f.write('OmegaBaryon ' + str(self.OmegaBaryon) + '\n') - f.write('HubbleParam ' + str(self.HubbleParam/100) + '\n') - f.write('BoxSize ' + str(self.BoxSize) + '\n') - f.write('\n') - f.write('%---- Tree algorithm, force accuracy, domain update frequency' + '\n') - f.write('OutputListOn ' + str(self.OutputListOn) + '\n') - f.write('TimeBetSnapshot ' + str(self.TimeBetSnapshot) + '\n') - f.write('TimeOfFirstSnapshot ' + str(self.TimeOfFirstSnapshot) + '\n') - f.write('TimeBetStatistics ' + str(self.TimeBetStatistics) + '\n') - f.write('NumFilesPerSnapshot ' + str(self.NumFilesPerSnapshot) + '\n') - f.write('NumFilesWrittenInParallel ' + str(self.NumFilesWrittenInParallel) + '\n') - f.write('\n') - f.write('%---- Accuracy of time integration' + '\n') - f.write('TypeOfTimestepCriterion ' + str(self.TypeOfTimestepCriterion) + '\n') - f.write('ErrTolIntAccuracy ' + str(self.ErrTolIntAccuracy) + '\n') - f.write('CourantFac ' + str(self.CourantFac) + '\n') - f.write('MaxRMSDisplacementFac ' + str(self.MaxRMSDisplacementFac) + '\n') - f.write('MaxSizeTimestep ' + str(self.MaxSizeTimestep) + '\n') - f.write('MinSizeTimestep ' + str(self.MinSizeTimestep) + '\n') - f.write('\n') - #f.write('%---- Accuracy of time integration') - f.write('InitGasTemp ' + str(self.InitGasTemp) + '\n') - f.write('MinGasTemp ' + str(self.MinGasTemp) + '\n') - f.write('\n') - f.write('%---- Tree algorithm, force accuracy, domain update frequency' + '\n') - f.write('TypeOfOpeningCriterion ' + str(self.TypeOfOpeningCriterion) + '\n') - f.write('ErrTolTheta ' + str(self.ErrTolTheta) + '\n') - f.write('ErrTolForceAcc ' + str(self.ErrTolForceAcc) + '\n') - f.write('TreeDomainUpdateFrequency ' + str(self.TreeDomainUpdateFrequency) + '\n') - f.write('\n') - f.write('%---- Initial density estimate' + '\n') - f.write('DesNumNgb ' + str(self.DesNumNgb) + '\n') - f.write('MaxNumNgbDeviation ' + str(self.MaxNumNgbDeviation) + '\n') - f.write('\n') - f.write('%---- System of units' + '\n') - f.write('UnitLength_in_cm ' + str(self.UnitLength_in_cm) + '\n') - f.write('UnitMass_in_g ' + str(self.UnitMass_in_g) + '\n') - f.write('UnitVelocity_in_cm_per_s ' + str(self.UnitVelocity_in_cm_per_s) + '\n') - f.write('GravityConstantInternal ' + str(self.GravityConstantInternal) + '\n') - f.write('\n') - f.write('%---- Gravitational softening lengths' + '\n') - f.write('MinGasHsmlFractional ' + str(self.MinGasHsmlFractional) + '\n') - f.write('\n') - f.write('SofteningGas ' + str(self.SofteningGas) + '\n') - f.write('SofteningHalo ' + str(self.SofteningHalo) + '\n') - f.write('SofteningDisk ' + str(self.SofteningDisk) + '\n') - f.write('SofteningBulge ' + str(self.SofteningBulge) + '\n') - f.write('SofteningStars ' + str(self.SofteningStars) + '\n') - f.write('SofteningBndry ' + str(self.SofteningBndry) + '\n') - f.write('\n') - f.write('SofteningGasMaxPhys ' + str(self.SofteningGasMaxPhys) + '\n') - f.write('SofteningHaloMaxPhys ' + str(self.SofteningHaloMaxPhys) + '\n') - f.write('SofteningDiskMaxPhys ' + str(self.SofteningDiskMaxPhys) + '\n') - f.write('SofteningBulgeMaxPhys ' + str(self.SofteningBulgeMaxPhys) + '\n') - f.write('SofteningStarsMaxPhys ' + str(self.SofteningStarsMaxPhys) + '\n') - f.write('SofteningBndryMaxPhys ' + str(self.SofteningBndryMaxPhys) + '\n') - f.write('\n') - f.write('%---- non-common' + '\n') - f.write('ArtBulkViscConst ' + str(self.ArtBulkViscConst) + '\n') - - if includesub == True: - f.write('ErrTolThetaSubfind ' + str(self.ErrTolThetaSubfind) + '\n') - f.write('DesLinkNgb ' + str(self.DesLinkNgb) + '\n') - - f.close() - - - def _checkexistence_button_fired(self): - - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - - ax = self.main.display.axes[0] - ax.set_xticks([]) - ax.set_yticks([]) - - titlestr = 'HALO BOXTYPE PAD LMIN LMAX NVIR ICS GADGET HALOS' - - placenormtext(ax,0.03, 0.98,'HALO',10) - placenormtext(ax,0.15, 0.98,'BOXTYPE',10) - placenormtext(ax,0.60, 0.98,'PAD',10) - placenormtext(ax,0.40, 0.98,'LMIN',10) - placenormtext(ax,0.50, 0.98,'LMAX',10) - placenormtext(ax,0.31, 0.98,'NVIR',10) - placenormtext(ax,0.70, 0.98,'ICS',10) - placenormtext(ax,0.77, 0.98,'GADGET',10) - placenormtext(ax,0.91, 0.98,'HALOS',10) - txtdisplace = 0.0 - for boxtypei in self.boxtype: - for nrviri in self.nrvir: - for paddingi in self.padding: - for overlapi in self.overlap: - foldername = 'H' + str(self.haloid) + \ - '_B' + str(boxtypei.upper())[0] + \ - '_Z' + str(self.zinit) + \ - '_P' + str(paddingi) + \ - '_LN' + str(self.lmin) + \ - '_LX' + str(self.lmax) + \ - '_O' + str(overlapi) + \ - '_NV' + str(nrviri) - - filepath = self.gadpath + 'halos/H' + str(self.haloid) + '/' + foldername - - #print filepath - #print filepath + '/ics.0' - #print filepath + '/outputs/snapdir_064' - #print filepath + '/outputs/groups_064' - try: - with open(filepath + '/ics.0'): - icfound = '+' - except IOError: - icfound = '-' - if os.path.exists(filepath + '/outputs/snapdir_063'): - gadfound = '+' - else: - gadfound = '-' - - if os.path.exists(filepath + '/outputs/groups_063'): - halosfound = '+' - else: - halosfound = '-' - - txtdisplace += 0.03 - placenormtext(ax,0.02, 0.98 - txtdisplace,str(self.haloid),10) - placenormtext(ax,0.14, 0.98 - txtdisplace,str(boxtypei.upper()),10) - placenormtext(ax,0.615, 0.98 - txtdisplace,str(paddingi),10) - placenormtext(ax,0.42, 0.98 - txtdisplace,str(self.lmin),10) - placenormtext(ax,0.52, 0.98 - txtdisplace,str(self.lmax),10) - placenormtext(ax,0.325, 0.98 - txtdisplace,str(nrviri),10) - placenormtext(ax,0.71, 0.98 - txtdisplace,str(icfound),10) - placenormtext(ax,0.81, 0.98 - txtdisplace,str(gadfound),10) - placenormtext(ax,0.94, 0.98 - txtdisplace,str(halosfound),10) - - wx.CallAfter(self.main.display.canvas.draw) - - def __init__(self, main, **kwargs): - HasTraits.__init__(self) - self.main = main - self.boxtype = ['ellipsoid'] - self.nvir = ['3','4','5','6','7','8','9'] - self.padding = ['7','8','9'] - self.overlap = ['4'] - self.lmax = '11' - self.cosmologylist = ['PLANCK'] - self.haloidlist = self.main.candidatestab.haloid - self.gadpath = self.main.headertab.datamasterpath - self.nintervals = '64' - self.redshifti = 46. - self.expfacti = 1./(1.+self.redshifti) - self.InitCondFile = './ics_rewrite' - self.OutputDir = './outputs' - self.EnergyFile = 'energy.txt' - self.InfoFile = 'info.txt' - self.TimingsFile = 'timings.txt' - self.CpuFile = 'cpu.txt' - self.RestartFile = 'restart' - self.SnapshotFileBase = 'snap' - self.OutputListFilename = 'ExpansionList' - self.TimebinFile = 'timebin' - self.ICFormat = 1 - self.SnapFormat = 1 - self.TimeLimitCPU = 340000 # in seconds - self.CpuTimeBetRestartFile = 43200 # in seconds - self.ResubmitOn = 0 - self.ResubmitCommand = 'my-scriptfile' - self.MaxMemSize = 3500 # sets maximum memory use in MByte - self.PartAllocFactor = 2.5 - self.BufferSize = 100 # in MByte - self.TimeBegin = 0.0078125 #%z=127 - self.TimeMax = 1.0 - self.ComovingIntegrationOn = 1 - self.PeriodicBoundariesOn = 1 - self.CoolingOn = 0 - self.StarformationOn = 0 - self.Omega0 = 0.3175 # 0.276 - self.OmegaLambda = 0.6825 # 0.724 - self.OmegaBaryon = 0.0 - self.HubbleParam = 0.6711 # 0.703 - self.BoxSize = 100.0 - self.OutputListOn = 1 - self.TimeBetSnapshot = 0.0 - self.TimeOfFirstSnapshot = 0.0 - self.TimeBetStatistics = 0.01 - self.NumFilesPerSnapshot = 4 - self.NumFilesWrittenInParallel = 4 - self.TypeOfTimestepCriterion = 0 - self.ErrTolIntAccuracy = 0.012 - self.CourantFac = 0.15 - self.MaxRMSDisplacementFac = 0.125 - self.MaxSizeTimestep = 0.005 - self.MinSizeTimestep = 0.0 - self.InitGasTemp = 1000.0 - self.MinGasTemp = 5.0 - self.TypeOfOpeningCriterion = 1 - self.ErrTolTheta = 0.6 - self.ErrTolForceAcc = 0.0025 - self.TreeDomainUpdateFrequency = 0.01 - self.DesNumNgb = 64 - self.MaxNumNgbDeviation = 1 - self.UnitLength_in_cm = 3.085678e24 # 1.0 Mpc - self.UnitMass_in_g = 1.989e43 # 1.0e10 solar masses - self.UnitVelocity_in_cm_per_s = 1e5 # 1 km/sec - self.GravityConstantInternal = 0 - self.MinGasHsmlFractional = 0.25 - self.SofteningGas = 0.0 - self.SofteningHalo = 0.000610352 - self.SofteningDisk = 0.001220703 - self.SofteningBulge = 0.002441406 - self.SofteningStars = 0.004882813 - self.SofteningBndry = 0.009765625 - self.SofteningGasMaxPhys = 0.0 - self.SofteningHaloMaxPhys = 0.000610352 - self.SofteningDiskMaxPhys = 0.001220703 - self.SofteningBulgeMaxPhys = 0.002441406 - self.SofteningStarsMaxPhys = 0.004882813 - self.SofteningBndryMaxPhys = 0.009765625 - self.ArtBulkViscConst = 1.0 - self.ErrTolThetaSubfind = 0.7 - self.DesLinkNgb = 20 - self.FOF_SECONDARY_LINK_TYPES = '4+8+16+32' - self.ExpansionListArr = np.zeros(shape=(1,4)) - self.clusteropt = self.main.headertab.clusteropt - self.PBSexecute = "mpirun -np " + str(int(self.PBSncores*self.PBSnnodes)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - self.SLURMexecute = "mpirun -np " + str(int(self.SLURMcores)) + " ./P-Gadget3 ./param.txt 1>" + self.outfile + " 2>" + self.errorfile - - #self.PBSstring = \ - #"#!/bin/sh \n#PBS -l nodes=3:ppn=8 \n#PBS -N H190897LX9N1 \n#PBS -m be \n. /opt/torque/etc/openmpi-setup.sh \ncd /bigbang/data/AnnaGroup/caterpillar/halos/H190897/H190897_BE_Z127_P7_LN7_LX9_O4_NV1 \nmpirun -np 24 ./P-Gadget3 ./param.txt 1>OUTPUT 2>ERROR" - - - - diff --git a/header.py b/header.py deleted file mode 100644 index c7ea88d..0000000 --- a/header.py +++ /dev/null @@ -1,106 +0,0 @@ -from Common import * -import getpass -from os.path import expanduser -class Header(HasTraits): - - view = View(Item(name='clusteropt',label='Cluster' ,padding=5), - Item(name='currentdir' ,label='CWD',style='readonly',padding=5), - Item(name='username' ,label='Username',padding=5), - Item(name='masterpath',label='Home',padding=5), - Item(name='gadgetpath',label='Gadget',padding=5), - Item(name='musicpath',label='Music',padding=5), - Item(name='parentsimpath',label='Parent Sim.',padding=5), - Item(name='datamasterpath',label='Project Data',padding=5)) - - clusteropt = Enum(['antares','barrine','odyssey','macbook','spacebase','bigbang','macbook']) - username = Str - masterpath = Directory - homepath = Directory - gadgetpath = Directory - musicpath = Directory - datamasterpath = Directory - parentsimpath = Directory - currentdir = Directory - - def _username_changed(self): - self.masterpath = self.homepath + self.username - self.gadgetpath = self.masterpath + '/lib/P-Gadget3/' - self.parentsimpath = self.masterpath + '/AnnaGroup/caterpillar/parent/512Parent/' - self.datamasterpath = self.masterpath + '/projects/caterpillar/data/' - self.musicpath = self.homepath + self.username + '/lib/music/' - - def _clusteropt_changed(self): - self.homepath = expanduser("~") - -# if self.clusteropt == 'Macbook': -# self.homepath = '/home/' -# -# if self.clusteropt == 'antares': -# self.homepath = '/home/' -# -# if self.clusteropt == 'barrine': -# self.homepath = '/home/' -# -# if self.clusteropt == 'odyssey': -# self.homepath = '/n/home01/' -# -# if self.clusteropt == 'spacebase': -# self.homepath = '/spacebase/data/' -# - self.masterpath = self.homepath + self.username - - def __init__(self, main, **kwargs): - self.username = getpass.getuser() - self.homepath = expanduser("~") - if platform.node() == "csr-dyn-150.mit.edu": - self.clusteropt = 'macbook' - self.homepath = '/Users/griffen/Desktop/cme/' - self.masterpath = self.homepath + self.username - self.gadgetpath = self.masterpath + '/lib/P-Gadget3' - self.musicpath = self.masterpath+ '/lib/music' - self.datamasterpath = self.homepath + 'AnnaGroup/caterpillar/' - self.parentsimpath = self.homepath + 'AnnaGroup/caterpillar/parent/' - #self.homepath = '/Users/' -# - if platform.node() == "Brendans-MacBook-Pro.local": - self.homepath = '/Users/' -# - if platform.node() == 'antares': - self.clusteropt = 'antares' - - if platform.node() == 'barrine': - self.username = 'uqbgriff' - - if platform.node() == 'rclogin13.rc.fas.harvard.edu': - self.clustopt = 'odyssey' - self.homepath = '/n/home01/bgriffen/data/' - self.masterpath = self.homepath + self.username - self.gadgetpath = self.masterpath + '/lib/P-Gadget3' - self.musicpath = self.masterpath + '/lib/music' - self.datamasterpath = self.homepath + 'caterpillar/' - self.parentsimpath = self.homepath + 'caterpillar/parent/' - - if platform.node() == 'spacebase': - self.clusteropt = 'spacebase' - self.homepath = '/spacebase/data/' - self.masterpath = self.homepath + self.username - self.gadgetpath = self.masterpath + '/lib/P-Gadget3' - self.musicpath = self.masterpath+ '/lib/music' - self.datamasterpath = self.homepath + 'AnnaGroup/caterpillar/' - self.parentsimpath = self.homepath + 'AnnaGroup/caterpillar/parent/' - - if platform.node() == 'bigbang.mit.edu': - self.clusteropt = 'bigbang' - self.homepath = '/bigbang/data/' - self.masterpath = self.homepath + self.username - self.gadgetpath = self.masterpath + '/lib/P-Gadget3' - self.musicpath = self.masterpath + '/lib/music' - self.datamasterpath = self.homepath + 'AnnaGroup/caterpillar/' - self.parentsimpath = self.homepath + 'AnnaGroup/caterpillar/parent/' - - self.currentdir = os.getcwd() - self.masterpath = self.homepath + self.username - - - HasTraits.__init__(self) - self.main = main diff --git a/install.py b/install.py deleted file mode 100644 index 7a7fb1a..0000000 --- a/install.py +++ /dev/null @@ -1,205 +0,0 @@ -from Common import * - -class InstallFiles(HasTraits): - - view = View(VGroup( HGroup(Item('libpath',show_label=False,springy=True), - Item('makelibdir_button',show_label=False,springy=True)), - HGroup(Item('fftw2dir',show_label=False,springy=True), - Item('installfftw2_button',show_label=False,springy=True)), - HGroup(Item('fftw3dir',show_label=False,springy=True), - Item('installfftw3_button',show_label=False,springy=True)), - HGroup(Item('hdf5dir',show_label=False,springy=True), - Item('installhdf5_button',show_label=False,springy=True)), - HGroup(Item('gsldir',show_label=False,springy=True), - Item('installgsl_button',show_label=False,springy=True)), - HGroup(Item('zlibdir',show_label=False,springy=True), - Item('installzlib_button',show_label=False,springy=True)), - HGroup(Item('szipdir',show_label=False,springy=True), - Item('installszip_button',show_label=False,springy=True)))) - - libpath = Directory - fftw2dir = Directory - fftw3dir = Directory - gsldir = Directory - zlibdir = Directory - hdf5dir = Directory - szipdir = Directory - - installfftw2_button = Button('Install FFTW2') - installfftw3_button = Button('Install FFTW3') - installhdf5_button = Button('Install HDF5') - installgsl_button = Button('Install GSL') - installzlib_button = Button('Install ZLIB') - installszip_button = Button('Install SZIP') - makelibdir_button = Button('Make Directory') - - def _libpath_changed(self): - self.fftw2dir = self.libpath + '/fftw2' - self.fftw3dir = self.libpath + '/fftw3' - self.hdf5dir = self.libpath + '/hdf5' - self.gsldir = self.libpath + '/gsl' - self.zlibdir = self.libpath + '/zlib' - self.szipdir = self.libpath + '/szip' - - def _makelibdir_button_fired(self): - if not os.path.exists(str(self.libpath)): - os.makedirs(str(self.libpath)) - - def _installfftw2_button_fired(self): - tmplibpath = './lib/installs/fftw-2.1.5/' - print "Installing:",tmplibpath - - flagstr = '--enable-type-prefix --enable-mpi' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.fftw2dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - - flagstr = '--enable-float --enable-type-prefix --enable-mpi' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.fftw2dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - - flagstr = '--enable-mpi' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.fftw2dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - - flagstr = '--enable-float --enable-mpi' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.fftw2dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - - print "FFTW2 INSTALLED:",str(self.fftw2dir) - - def _installfftw3_button_fired(self): - tmplibpath = './lib/installs/fftw-3.3.3/' - print "Installing:",tmplibpath - - flagstr = '--enable-threads --enable-float' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.fftw3dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - - flagstr = '--enable-threads' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.fftw3dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - - flagstr = '--enable-type-prefix --enable-threads --enable-float ' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.fftw3dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - - flagstr = '--enable-type-prefix --enable-threads ' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.fftw3dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - - print "FFTW3 INSTALLED:",str(self.fftw3dir) - - def _installhdf5_button_fired(self): - tmplibpath = './lib/installs/hdf5-1.8.10/' - print "Installing:",tmplibpath - #(/home/bgriffen/lib/szip,/home/bgriffen/lib/zlib/include,/home/bgriffen/lib/zlib/lib) - #flagstr = '--enable-fortran --enable-cxx --with-szlib=%s --with-zlib=%s,%s' - flagstr = '--enable-fortran --enable-cxx' - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s %s" % (str(self.hdf5dir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - print "HDF5 INSTALLED:",str(self.hdf5dir) - - def _installgsl_button_fired(self): - tmplibpath = './lib/installs/gsl-1.9/' - print "Installing:",tmplibpath - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s" % str(self.gsldir)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - print "GSL INSTALLED:",str(self.gsldir) - - def _installszip_button_fired(self): - tmplibpath = './lib/installs/szip-2.1/' - print "Installing:",tmplibpath - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - flagstr = '--enable-shared=no --enable-static=yes' - os.system("./configure --prefix=%s %s" % (str(self.szipdir),flagstr)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - print "SZIP INSTALLED:",str(self.szipdir) - - def _installzlib_button_fired(self): - tmplibpath = './lib/installs/zlib-1.2.8/' - print "Installing:",tmplibpath - tmpdir = os.getcwd() - os.chdir(tmplibpath) - os.system("make clean") - os.system("./configure --prefix=%s" % str(self.zlibdir)) - os.system("make") - os.system("make install") - os.system("make clean") - os.chdir(tmpdir) - print "ZLIB INSTALLED:",str(self.zlibdir) - - def __init__(self, main, **kwargs): - HasTraits.__init__(self) - self.main = main - self.libpath = self.main.headertab.masterpath + '/lib' - self.fftw2dir = self.libpath + '/fftw2' - self.fftw3dir = self.libpath + '/fftw3' - self.hdf5dir = self.libpath + '/hdf5' - self.gsldir = self.libpath + '/gsl' - self.zlibdir = self.libpath + '/zlib' - \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e82cb0e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cme" +version = "0.1.0" +description = "Caterpillar Made Easy: a GUI for setting up, running and inspecting cosmological zoom-in simulations" +readme = "README.md" +requires-python = ">=3.9" +license = { file = "LICENSE" } +authors = [{ name = "Brendan Griffen", email = "contact+cme@grifflabs.dev" }] +keywords = ["cosmology", "simulation", "zoom-in", "gadget", "music", "rockstar"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Astronomy", +] + +# Only what the display-free modules need. The GUI needs the `gui` extra, and +# the simulation I/O readers (the `modules` package) are not distributed here -- +# see README.md. +dependencies = [ + "numpy>=1.21", + "pandas>=1.3", +] + +[project.optional-dependencies] +gui = [ + "matplotlib>=3.5", + "traits>=6.3", + "traitsui>=7.3", + "enable>=5.3", + "mayavi>=4.8", + "wxPython>=4.2", +] +dev = [ + "pytest>=7.0", + "ruff>=0.5", +] + +[project.urls] +Homepage = "https://github.com/bgriffen/cme" +Issues = "https://github.com/bgriffen/cme/issues" + +[project.scripts] +cme = "cme.app:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"] +ignore = [ + # TraitsUI view declarations are wide nested call chains where wrapping to + # 100 columns hurts more than it helps. + "E501", + # dict(a=1) reads better than {"a": 1} for keyword-style parameter sets, + # which is how the run-parameter dicts and test fixtures are written. + "C408", +] + +[tool.ruff.lint.per-file-ignores] +# The GUI modules and the app entry point share state through +# `from cme.ui.common import *`. Unpicking that means giving every tab an +# explicit import list; until then these two rules cannot fire usefully. +# `common.py` itself declares __all__, so the surface is at least explicit. +"src/cme/ui/*.py" = ["F403", "F405"] +"src/cme/app.py" = ["F403", "F405"] diff --git a/src/cme/__init__.py b/src/cme/__init__.py new file mode 100644 index 0000000..da77892 --- /dev/null +++ b/src/cme/__init__.py @@ -0,0 +1,5 @@ + +""" A tool for constructing and analysing cosmological simulations. """ + +__author__ = "Brendan Griffen " +__version__ = "0.1.0" diff --git a/main.py b/src/cme/app.py similarity index 82% rename from main.py rename to src/cme/app.py index 0f04bba..cb360f0 100644 --- a/main.py +++ b/src/cme/app.py @@ -1,19 +1,19 @@ +"""Application entry point: assembles the tabbed main window.""" -from header import Header -from install import InstallFiles -from ics import InitialConditions -from gadgetrun import GadgetRun -from halos import HaloFind -from mergertree import MergerTree -#from analysis import Analysis -from contam import Contamination -from candidates import Candidates - -from tvtk.pyface.scene_editor import SceneEditor -from mayavi.tools.mlab_scene_model import MlabSceneModel from mayavi.core.ui.mayavi_scene import MayaviScene +from mayavi.tools.mlab_scene_model import MlabSceneModel +from tvtk.pyface.scene_editor import SceneEditor + +from cme.ui.candidates import Candidates +from cme.ui.common import * +from cme.ui.contamination import Contamination +from cme.ui.gadget import GadgetRun +from cme.ui.halos import HaloFind +from cme.ui.home import Header +from cme.ui.ics import InitialConditions +from cme.ui.install import InstallFiles +from cme.ui.mergertree import MergerTree -from Common import * class ApplicationMain(HasTraits): @@ -21,27 +21,24 @@ class ApplicationMain(HasTraits): headertab = Instance(Header) installtab = Instance(InstallFiles) - #existencetab = Instance(Existence) initstab = Instance(InitialConditions) gadgetruntab = Instance(GadgetRun) halofindtab = Instance(HaloFind) mergertreetab = Instance(MergerTree) candidatestab = Instance(Candidates) contaminationtab = Instance(Contamination) - #analysistab = Instance(Analysis) display = Instance(Figure) markercolor = ColorTrait markerstyle = Enum(['+',',','*','s','p','d','o']) markersize = Range(0,10,2) - + left_panel = Tabbed(Group(VGroup(Item('display', editor=MPLFigureEditor(),show_label=False, resizable=True)), HGroup(Item(name='markercolor', label="Color", style="custom",springy=True), Item(name='markerstyle', label="Marker",springy=True), Item(name='markersize', label="Size",springy=True)), label='Display'), Item(name='scene',label='Mayavi',editor=SceneEditor(scene_class=MayaviScene)),show_labels=False) -# right_panel = Tabbed(Item('headertab', style='custom', label='Home',show_label=False), Item('installtab', style='custom', label='Install',show_label=False), Item('candidatestab', style='custom', label='Candidates',show_label=False), @@ -50,8 +47,7 @@ class ApplicationMain(HasTraits): Item('halofindtab', style='custom', label='Halo Finder',show_label=False), Item('mergertreetab', style='custom', label='Merger Tree',show_label=False), Item('contaminationtab', style='custom', label='Contam.',show_label=False)) - #Item('analysistab', style='custom', label='Analysis',show_label=False)) - + view = View(HSplit(left_panel, right_panel), width = 1280, @@ -69,7 +65,7 @@ def _display_default(self): ax.set_ylabel('Y') ax.set_xlim(0,1) ax.set_ylim(0,1) - + # Set matplotlib canvas colour to be white rect = figure.patch rect.set_facecolor('w') @@ -95,39 +91,40 @@ def _halofindtab_default(self): def _mergertreetab_default(self): return MergerTree(self) - def _analysistab_default(self): - return Analysis(self) - def _candidatestab_default(self): return Candidates(self) - + def _contaminationtab_default(self): return Contamination(self) def _markercolor_changed(self): - ax = self.display.axes[0] - if hasattr(self, 'display_points'): + if hasattr(self, 'display_points'): self.display_points.set_color(self.markercolor) self.display_points.set_markeredgecolor(self.markercolor) wx.CallAfter(self.display.canvas.draw) def _markerstyle_changed(self): - ax = self.display.axes[0] - if hasattr(self, 'display_points'): + if hasattr(self, 'display_points'): self.display_points.set_marker(self.markerstyle) wx.CallAfter(self.display.canvas.draw) def _markersize_changed(self): - ax = self.display.axes[0] - if hasattr(self, 'display_points'): + if hasattr(self, 'display_points'): self.display_points.set_markersize(self.markersize) wx.CallAfter(self.display.canvas.draw) def __init__(self, **kwargs): + # HasTraits.__init__ was previously never called, so trait + # initialisation was skipped and any kwargs were silently dropped. + HasTraits.__init__(self, **kwargs) self.markercolor = 'blue' self.markersize = 2 self.markerstyle = 'o' +def main(): + """Launch the application. Wired up as the ``cme`` console script.""" + ApplicationMain().configure_traits() + + if __name__ == '__main__': - app = ApplicationMain() - app.configure_traits() + main() diff --git a/src/cme/config.py b/src/cme/config.py new file mode 100644 index 0000000..5609b38 --- /dev/null +++ b/src/cme/config.py @@ -0,0 +1,138 @@ +"""Cluster profiles loaded from ``clusters.toml``. + +Replaces the ``platform.node()`` if-chain in the Home tab, which hardcoded +absolute paths, two usernames and an email address, so the tool only worked for +its original author on five named machines. + +Lookup order for the configuration file: + +1. ``$CME_CLUSTERS`` if set +2. ``~/.config/cme/clusters.toml`` +3. ``clusters.toml`` beside the installed package, or at the repository root +""" + +import getpass +import os +import platform + +try: # Python 3.11+ + import tomllib +except ModuleNotFoundError: # pragma: no cover - 3.9/3.10 fallback + try: + import tomli as tomllib + except ModuleNotFoundError: + tomllib = None + +#: Keys whose values get placeholder expansion. +PATH_KEYS = ("gadget", "music", "data", "parent") + +_FALLBACK = { + "default": "local", + "clusters": { + "local": { + "hostnames": [], + "gadget": "{master}/lib/P-Gadget3", + "music": "{master}/lib/music", + "data": "{master}/projects/caterpillar/data", + "parent": "{master}/projects/caterpillar/parent", + } + }, +} + + +def config_path(): + """Path to the clusters file that will be used, or None if none is found.""" + override = os.environ.get("CME_CLUSTERS") + if override: + return override if os.path.exists(override) else None + + candidates = [ + os.path.expanduser("~/.config/cme/clusters.toml"), + os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "clusters.toml", + ), + os.path.join(os.path.dirname(os.path.abspath(__file__)), "clusters.toml"), + ] + for path in candidates: + if os.path.exists(path): + return path + return None + + +def load(path=None): + """Read the clusters file, falling back to a single local profile. + + The fallback keeps the application usable on a machine with no + configuration and no TOML parser available, rather than failing to start. + """ + path = path or config_path() + if path is None or tomllib is None: + return _FALLBACK + with open(path, "rb") as handle: + data = tomllib.load(handle) + if not data.get("clusters"): + return _FALLBACK + return data + + +def profile_names(config=None): + """Available profile names, for the Home tab's cluster selector.""" + return sorted((config or load())["clusters"]) + + +def detect(config=None, hostname=None): + """Name of the profile matching this machine, or the configured default.""" + config = config or load() + hostname = hostname or platform.node() + for name, profile in config["clusters"].items(): + if hostname in profile.get("hostnames", []): + return name + default = config.get("default") + if default in config["clusters"]: + return default + return profile_names(config)[0] + + +def resolve(name=None, config=None, username=None): + """Return one profile's paths with placeholders expanded. + + Keys: ``name``, ``username``, ``home``, ``master``, plus the entries in + :data:`PATH_KEYS` and any scheduler settings the profile carries. + + A profile with a ``home_root`` treats it as the directory containing + per-user homes, so the user's own directory sits below it. Without one, the + expanded home directory already identifies the user and the name is not + appended: + + >>> resolve("local", username="alice")["master"] == os.path.expanduser("~") + True + """ + config = config or load() + name = name or detect(config) + if name not in config["clusters"]: + raise ValueError(f"unknown cluster {name!r}; expected one of {profile_names(config)}") + profile = config["clusters"][name] + + user = username or profile.get("username") or getpass.getuser() + home = profile.get("home_root") or os.path.expanduser("~") + # A home_root is the directory *containing* per-user homes, so the user's + # own directory sits below it. Without a home_root, the expanded home + # directory already includes the user name. + master = os.path.join(home, user) if profile.get("home_root") else home + + fields = {"home": home, "user": user, "master": master} + resolved = { + "name": name, + "username": user, + "home": home, + "master": master, + "scheduler": profile.get("scheduler", "PBS"), + "queue": profile.get("queue", "default"), + "extra_lines": profile.get("extra_lines", ""), + "extra_flags": profile.get("extra_flags", ""), + } + for key in PATH_KEYS: + template = profile.get(key) + resolved[key] = template.format(**fields) if template else "" + return resolved diff --git a/src/cme/cosmology.py b/src/cme/cosmology.py new file mode 100644 index 0000000..80d5b4e --- /dev/null +++ b/src/cme/cosmology.py @@ -0,0 +1,154 @@ +"""Named cosmological parameter sets. + +This replaces ``cosmoconstant()`` from the external ``grifflib`` module, which +is not distributed with this repository, and the six hardcoded copies of the +Hubble parameter that were scattered through the GUI tabs. + +.. warning:: + + Because ``grifflib`` is absent, the values below were taken from the + published papers cited against each set rather than copied from the + original implementation. The PLANCK row cross-checks exactly against the + values hardcoded in the Gadget tab (Omega0 = 0.3175, OmegaLambda = 0.6825, + h = 0.6711), which is good evidence the convention matches. If the original + ``grifflib`` is recovered, diff it against this table before trusting a + non-PLANCK set for new science. + +``h`` is the dimensionless Hubble parameter; ``H0`` is in km/s/Mpc. MUSIC +configuration files want ``H0``, Gadget parameter files want ``h``, which is +why both are exposed rather than one being derived at each call site. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Cosmology: + """One set of cosmological parameters.""" + + name: str + omega_m: float + omega_l: float + omega_b: float + h: float + sigma_8: float + n_spec: float + reference: str + + @property + def H0(self): + """Hubble constant in km/s/Mpc, as MUSIC's ``H0`` key expects.""" + return self.h * 100.0 + + def as_tuple(self): + """The 6-tuple the GUI tabs unpack, in ``cosmoconstant()``'s order. + + Note the fourth element is ``H0`` in km/s/Mpc, not ``h``: the Gadget + tab divides it by 100 when writing ``HubbleParam``, and the MUSIC + writer uses it directly. + """ + return ( + self.omega_m, + self.omega_l, + self.omega_b, + self.H0, + self.sigma_8, + self.n_spec, + ) + + +WMAP1 = Cosmology( + name="WMAP1", + omega_m=0.270, + omega_l=0.730, + omega_b=0.0440, + h=0.710, + sigma_8=0.840, + n_spec=0.990, + reference="Spergel et al. 2003, ApJS 148, 175", +) + +WMAP3 = Cosmology( + name="WMAP3", + omega_m=0.234, + omega_l=0.766, + omega_b=0.0407, + h=0.734, + sigma_8=0.760, + n_spec=0.961, + reference="Spergel et al. 2007, ApJS 170, 377", +) + +WMAP5 = Cosmology( + name="WMAP5", + omega_m=0.258, + omega_l=0.742, + omega_b=0.0441, + h=0.719, + sigma_8=0.796, + n_spec=0.963, + reference="Komatsu et al. 2009, ApJS 180, 330", +) + +WMAP7 = Cosmology( + name="WMAP7", + omega_m=0.272, + omega_l=0.728, + omega_b=0.0455, + h=0.704, + sigma_8=0.807, + n_spec=0.961, + reference="Komatsu et al. 2011, ApJS 192, 18", +) + +WMAP9 = Cosmology( + name="WMAP9", + omega_m=0.2865, + omega_l=0.7135, + omega_b=0.0463, + h=0.6932, + sigma_8=0.820, + n_spec=0.9608, + reference="Hinshaw et al. 2013, ApJS 208, 19", +) + +PLANCK = Cosmology( + name="PLANCK", + omega_m=0.3175, + omega_l=0.6825, + omega_b=0.0490, + h=0.6711, + sigma_8=0.8344, + n_spec=0.9624, + reference="Planck Collaboration 2014, A&A 571, A16 (Planck 2013 XVI)", +) + +#: Keyed by the names the GUI's CheckListEditor offers. +COSMOLOGIES = {c.name: c for c in (WMAP1, WMAP3, WMAP5, WMAP7, WMAP9, PLANCK)} + +#: The Caterpillar suite was run with Planck 2013. Tabs that analyse existing +#: output and have no cosmology selector of their own assume this, which is +#: what the six hardcoded 0.6711 literals were doing implicitly. +DEFAULT = PLANCK + + +def get(name): + """Look up a cosmology by name, case-insensitively. + + >>> get("planck").h + 0.6711 + """ + try: + return COSMOLOGIES[str(name).upper()] + except KeyError: + raise ValueError( + f"unknown cosmology {name!r}; expected one of {sorted(COSMOLOGIES)}" + ) from None + + +def cosmoconstant(name): + """Compatibility shim matching the old ``grifflib.cosmoconstant`` signature. + + Returns ``(omega_m, omega_l, omega_b, H0, sigma_8, n_spec)``. + """ + return get(name).as_tuple() diff --git a/src/cme/naming.py b/src/cme/naming.py new file mode 100644 index 0000000..2f7f256 --- /dev/null +++ b/src/cme/naming.py @@ -0,0 +1,180 @@ +"""The run-directory and Lagrangian-region path conventions. + +A resimulation is identified by its full parameter set, encoded into a +directory name:: + + H_B_Z_P_LN_LX_O_NV + +for example ``H190897_BE_Z127_P7_LN7_LX11_O4_NV4``. + +This convention was previously rebuilt inline in 18 places across the GUI +tabs, which is how the two inconsistencies fixed here arose: the +contamination and halo tabs hardcoded ``_Z127`` rather than reading their own +``zinit``, and Gadget snapshot indices were zero-padded by string +concatenation rather than to a fixed width. Both are covered by tests. +""" + +import os +import re + +#: Gadget zero-pads snapshot and group directory indices to three digits. +GADGET_INDEX_WIDTH = 3 + +#: The starting redshift used throughout the Caterpillar suite. +DEFAULT_ZINIT = 127 + +#: Single-letter codes for the MUSIC refinement region shape. +REGION_CODES = {"box": "B", "ellipsoid": "E"} + +_RUN_DIR_RE = re.compile( + r"^H(?P\d+)" + r"_B(?P[BE])" + r"_Z(?P\d+)" + r"_P(?P\d+)" + r"_LN(?P\d+)" + r"_LX(?P\d+)" + r"_O(?P\d+)" + r"_NV(?P\d+)$" +) + + +def region_code(region): + """Return the single-letter code for a refinement region shape. + + >>> region_code("ellipsoid") + 'E' + >>> region_code("box") + 'B' + """ + try: + return REGION_CODES[str(region).lower()] + except KeyError: + raise ValueError( + f"unknown region {region!r}; expected one of {sorted(REGION_CODES)}" + ) from None + + +def run_dir_name( + halo_id, + region, + padding, + levelmin, + levelmax, + overlap, + nrvir, + zinit=DEFAULT_ZINIT, +): + """Build the directory name encoding a resimulation's parameters. + + Numeric arguments are coerced with ``int``, because the GUI supplies them + either as integer ``Range`` traits or as strings from a ``CheckListEditor`` + depending on the tab. + + >>> run_dir_name(190897, "ellipsoid", 7, 7, 11, 4, 4) + 'H190897_BE_Z127_P7_LN7_LX11_O4_NV4' + """ + return ( + f"H{int(halo_id)}" + f"_B{region_code(region)}" + f"_Z{int(zinit)}" + f"_P{int(padding)}" + f"_LN{int(levelmin)}" + f"_LX{int(levelmax)}" + f"_O{int(overlap)}" + f"_NV{int(nrvir)}" + ) + + +def parse_run_dir_name(name): + """Inverse of :func:`run_dir_name`; returns a dict, or ``None`` if no match. + + >>> parse_run_dir_name("H190897_BE_Z127_P7_LN7_LX11_O4_NV4")["levelmax"] + 11 + """ + match = _RUN_DIR_RE.match(str(name)) + if match is None: + return None + parts = match.groupdict() + code = parts.pop("region_code") + parsed = {key: int(value) for key, value in parts.items()} + parsed["region"] = next(k for k, v in REGION_CODES.items() if v == code) + return parsed + + +def halo_dir(base, halo_id): + """Directory holding every resimulation of one halo.""" + return os.path.join(str(base), "halos", f"H{int(halo_id)}") + + +def run_dir(base, halo_id, **kwargs): + """Full path to one resimulation's directory. + + ``kwargs`` are forwarded to :func:`run_dir_name`. + """ + return os.path.join(halo_dir(base, halo_id), run_dir_name(halo_id=halo_id, **kwargs)) + + +def outputs_dir(base, halo_id, **kwargs): + """Directory Gadget writes snapshots and group catalogues into.""" + return os.path.join(run_dir(base, halo_id, **kwargs), "outputs") + + +def job_name(halo_id, region, padding, levelmax, nrvir): + """Short scheduler job name for a resimulation. + + The halo ID is truncated to two digits to keep the name inside queue name + limits, matching the existing convention. + + >>> job_name(190897, "ellipsoid", 7, 11, 4) + 'H19EP7L11N4' + """ + return ( + f"H{str(int(halo_id))[:2]}{region_code(region)}P{int(padding)}L{int(levelmax)}N{int(nrvir)}" + ) + + +def rockstar_dir(base, halo_id, **kwargs): + """Directory holding one resimulation's Rockstar catalogue.""" + return os.path.join(run_dir(base, halo_id, **kwargs), "rockstardata") + + +def snapshot_index(snapnum): + """Zero-pad a snapshot number to Gadget's fixed width. + + The previous inline form was ``"snapdir_0" + str(snapnum)``, which is only + correct for two-digit snapshot numbers -- it produced ``snapdir_05`` + instead of ``snapdir_005``, and ``snapdir_0255`` instead of ``snapdir_255``. + + >>> snapshot_index(63), snapshot_index(5), snapshot_index(255) + ('063', '005', '255') + """ + return f"{int(snapnum):0{GADGET_INDEX_WIDTH}d}" + + +def snapshot_dir(outputs, snapnum): + """Path to one snapshot's directory inside an ``outputs`` directory.""" + return os.path.join(str(outputs), f"snapdir_{snapshot_index(snapnum)}") + + +def snapshot_base(outputs, snapnum): + """Path stem the Gadget readers expect for a snapshot (no file suffix).""" + return os.path.join(snapshot_dir(outputs, snapnum), f"snap_{snapshot_index(snapnum)}") + + +def groups_dir(outputs, snapnum): + """Path to one snapshot's FOF/SUBFIND group catalogue directory.""" + return os.path.join(str(outputs), f"groups_{snapshot_index(snapnum)}") + + +def lagr_region_path(lagr_dir, halo_id, nrvir): + """Path to the MUSIC region point file for one halo and ``nrvir``. + + >>> lagr_region_path("/data/ics/lagr", 190897, 4) + '/data/ics/lagr/H190897NRVIR4' + """ + return os.path.join(str(lagr_dir), f"H{int(halo_id)}NRVIR{int(nrvir)}") + + +def lagr_header_path(lagr_dir, halo_id, nrvir): + """Path to the ``.head`` file holding a region's centre and extent.""" + return lagr_region_path(lagr_dir, halo_id, nrvir) + ".head" diff --git a/src/cme/science/__init__.py b/src/cme/science/__init__.py new file mode 100644 index 0000000..2761906 --- /dev/null +++ b/src/cme/science/__init__.py @@ -0,0 +1,7 @@ +"""Display-free computation. + +Functions here take arrays and catalogues and return arrays and catalogues. +They do not touch matplotlib, Traits, or the filesystem, so they can be tested +against synthetic inputs with a known answer and reused to drive a suite +non-interactively. +""" diff --git a/src/cme/science/candidates.py b/src/cme/science/candidates.py new file mode 100644 index 0000000..a63960f --- /dev/null +++ b/src/cme/science/candidates.py @@ -0,0 +1,163 @@ +"""Selection of isolated Milky-Way-analogue host haloes. + +A candidate must fall inside a virial mass range and satisfy a set of isolation +criteria, each of the form "no halo above mass M lies within distance R". + +Extracted from the Candidates tab, where the same logic was interleaved with +matplotlib calls inside a button handler. Three defects came with it: + +* the results array was seeded with ``np.zeros((1, 6))`` and grown by + ``vstack``, so every selection carried a phantom zero-mass, zero-position + candidate that was plotted, counted in the tally, and accepted by the "add + halo" membership test (AUDIT 2.2.3); +* the six position and mass arrays were re-extracted from the catalogue inside + the per-candidate loop, making the scan O(N^2) in the base sample + (AUDIT 4.5); +* ``vstack`` per accepted candidate copied the whole result set each time. + +Masses are expected in Msol/h and positions in Mpc/h, as Rockstar writes them; +``hubble`` converts both to physical units before any comparison. +""" + +from dataclasses import dataclass + +import numpy as np + +#: Columns returned by :func:`select`, in order. +COLUMNS = ("id", "mvir", "rvir", "posX", "posY", "posZ") + + +@dataclass(frozen=True) +class ExclusionZone: + """One isolation criterion: no halo above a mass within ``distance``. + + Exactly one of ``mass`` (an absolute threshold in Msol) or ``mass_multiple`` + (a multiple of the candidate's own mass) must be given. A relative + threshold excludes the candidate itself from the comparison, since a halo is + trivially within zero distance of itself. + """ + + distance: float + mass: float = None + mass_multiple: float = None + + def __post_init__(self): + if (self.mass is None) == (self.mass_multiple is None): + raise ValueError( + "specify exactly one of mass or mass_multiple, " + f"got mass={self.mass!r}, mass_multiple={self.mass_multiple!r}" + ) + + @property + def is_relative(self): + return self.mass_multiple is not None + + +def _physical(catalogue, hubble): + """Extract positions and masses once, in physical units.""" + return ( + np.asarray(catalogue["id"]), + np.asarray(catalogue["mvir"], dtype=np.float64) / hubble, + np.asarray(catalogue["rvir"], dtype=np.float64), + np.asarray(catalogue["posX"], dtype=np.float64), + np.asarray(catalogue["posY"], dtype=np.float64), + np.asarray(catalogue["posZ"], dtype=np.float64), + ) + + +def _min_distance(x, y, z, xs, ys, zs, hubble, exclude=None): + """Distance in Mpc from one point to the nearest of many, or inf if none.""" + if exclude is not None: + xs, ys, zs = xs[exclude], ys[exclude], zs[exclude] + if xs.size == 0: + return np.inf + return float(np.sqrt((x - xs) ** 2 + (y - ys) ** 2 + (z - zs) ** 2).min() / hubble) + + +def select(hosts, mass_range, zones, hubble): + """Return the isolated candidates in ``hosts`` as a structured array. + + ``hosts`` is a host-halo catalogue: any mapping of the columns in + :data:`COLUMNS` to sequences, such as the DataFrame from + ``RSDataReader.get_hosts()``. + + ``mass_range`` is ``(low, high)`` in Msol, applied to the physical virial + mass. ``zones`` is an iterable of :class:`ExclusionZone`. + + The returned array has the dtype implied by :data:`COLUMNS` and is empty -- + not one row of zeros -- when nothing qualifies. + + >>> import numpy as np + >>> cat = {"id": [1], "mvir": [1e12], "rvir": [200.0], + ... "posX": [10.0], "posY": [10.0], "posZ": [10.0]} + >>> out = select(cat, (7e11, 7e12), [], hubble=1.0) + >>> out["id"], len(out) + (array([1]), 1) + """ + low, high = mass_range + zones = list(zones) + + ids, mvir, rvir, x, y, z = _physical(hosts, hubble) + + in_range = (mvir > low) & (mvir < high) + candidate_index = np.flatnonzero(in_range) + + # Pre-compute the comparison population for every absolute-threshold zone + # once, rather than per candidate. + absolute = {} + for zone in zones: + if not zone.is_relative: + absolute[id(zone)] = mvir > zone.mass + + accepted = [] + for i in candidate_index: + for zone in zones: + if zone.is_relative: + above = mvir >= zone.mass_multiple * mvir[i] + # A halo is zero distance from itself, so drop it. + above &= ids != ids[i] + else: + above = absolute[id(zone)] + + if _min_distance(x[i], y[i], z[i], x, y, z, hubble, above) < zone.distance: + break + else: + accepted.append((ids[i], mvir[i], rvir[i], x[i], y[i], z[i])) + + dtype = [("id", np.int64)] + [(name, np.float64) for name in COLUMNS[1:]] + return np.array(accepted, dtype=dtype) + + +def counts(hosts, mass_range, zones, hubble): + """Population sizes the GUI displays alongside the candidate count. + + Returns a dict with the base-sample size and, per absolute-threshold zone, + the number of haloes above that zone's mass. + """ + low, high = mass_range + _, mvir, *_ = _physical(hosts, hubble) + result = {"base_sample": int(((mvir > low) & (mvir < high)).sum())} + for n, zone in enumerate(zones, start=1): + if not zone.is_relative: + result[f"above_zone{n}"] = int((mvir > zone.mass).sum()) + return result + + +def to_text(candidates): + """Serialise a selection to the ``candidates.dat`` format. + + One row per candidate, with the header the original writer emitted. + """ + lines = ["# ID M Rvir x y z"] + for row in candidates: + lines.append( + "{:f} {:e} {:f} {:f} {:f} {:f} ".format( + int(row["id"]), + row["mvir"], + row["rvir"], + row["posX"], + row["posY"], + row["posZ"], + ) + ) + return "\n".join(lines) + "\n" diff --git a/src/cme/shell.py b/src/cme/shell.py new file mode 100644 index 0000000..644f3c3 --- /dev/null +++ b/src/cme/shell.py @@ -0,0 +1,74 @@ +"""Running external commands without a shell. + +Every external invocation in this codebase used to be assembled by string +concatenation and handed to ``os.system`` or ``subprocess.call(..., shell=True)`` +with GUI-supplied paths interpolated in. That is wrong in two ways that both +bite in practice on a shared cluster: + +* a path containing a space silently splits into two arguments, so + ``/data/My Runs/H190897`` becomes ``/data/My`` and ``Runs/H190897``; +* a path containing shell metacharacters executes. ``;``, ``$(...)`` and ``&&`` + are all legal in a directory name. + +Passing an argument list means the arguments reach the program exactly as +given, with no shell involved. ``cwd`` replaces the ``os.chdir`` / ``chdir`` +back pairs the original used, which leaked the working directory on any +exception. +""" + +import logging +import os +import subprocess + +log = logging.getLogger(__name__) + + +def run(args, cwd=None, check=False): + """Run one command from an argument list, with no shell. + + ``args`` is a sequence whose elements are stringified, so path traits and + numbers can be passed directly. + + Returns the :class:`subprocess.CompletedProcess`. With ``check=True`` a + non-zero exit raises :class:`subprocess.CalledProcessError`. + """ + argv = [str(a) for a in args] + log.info("running %s%s", argv, f" in {cwd}" if cwd else "") + return subprocess.run(argv, cwd=cwd, check=check) + + +def run_all(commands, cwd=None, check=False): + """Run several commands in sequence in the same directory. + + Stops at the first failure and returns the results so far, so a broken + ``configure`` does not lead to ``make install`` running anyway -- which the + original ``;``-joined command strings did. + """ + results = [] + for args in commands: + result = run(args, cwd=cwd, check=check) + results.append(result) + if result.returncode != 0: + log.warning( + "command failed (%s), stopping: %s", result.returncode, [str(a) for a in args] + ) + break + return results + + +def remove_matching(directory, patterns): + """Delete files in ``directory`` matching any of ``patterns``. + + Replaces ``rm wnoise* temp*`` and friends. Globbing in Python keeps the + expansion scoped to one directory: a shell ``rm`` with an empty or + unexpected path variable can match far more than intended. + """ + import glob as globmod + + removed = [] + for pattern in patterns: + for path in globmod.glob(os.path.join(str(directory), pattern)): + if os.path.isfile(path): + os.remove(path) + removed.append(path) + return removed diff --git a/src/cme/ui/__init__.py b/src/cme/ui/__init__.py new file mode 100644 index 0000000..dcceb22 --- /dev/null +++ b/src/cme/ui/__init__.py @@ -0,0 +1,7 @@ +"""TraitsUI tabs. + +Each module here defines one ``HasTraits`` tab of the main window. Tabs are +intentionally thin: they bind widgets to state and delegate the actual work to +the display-free modules in :mod:`cme.naming`, :mod:`cme.cosmology`, +:mod:`cme.science` and :mod:`cme.writers`. +""" diff --git a/candidates.py b/src/cme/ui/candidates.py similarity index 54% rename from candidates.py rename to src/cme/ui/candidates.py index 7e53ec1..dea1e29 100644 --- a/candidates.py +++ b/src/cme/ui/candidates.py @@ -1,5 +1,7 @@ -from Common import * -from numpy.random import random +from cme import cosmology, writers +from cme.science import candidates as sci +from cme.ui.common import * + class Candidates(HasTraits): @@ -41,16 +43,12 @@ class Candidates(HasTraits): nlargerthanuppermasscut = Int nbetweenmasscut = Int ntotalcandidates = Int - upper = Float(15.) - + candidatearr = Array loadpreviouscand = Bool(True) - - #candidatearr = random((20, 6)) + haloid = List(Int) - #haloid = Enum(['190897','208737','140666','28221','147419','28188','147273','78411','131988','19910']) - #haloid = Enum() jobstatus = Str() addhalo_button = Button("ADD") haloidtoadd = Int() @@ -59,14 +57,14 @@ class Candidates(HasTraits): view = View(Tabbed(Group(Group(Item(name='halopath',label='Parent Simulation Path'), HGroup(Item(name='loadpreviouscand',label='Use Existing Candidates'), Item(name='exclusionstr',show_label=False,style='readonly')), - + Group(HGroup(Item(name='lowermasscut',label='log[M > Msol]',springy=True,format_str= '%.1f'), Item(name='normlowermasscut',label='M',width=-70,format_str='%.2e')), HGroup(Item(name='uppermasscut',label='log[M > Msol]',springy=True,format_str= '%.1f'), Item(name='normuppermasscut',label='M',width=-70,format_str='%.2e')) ,label='Mass Range For Base Sample',show_border=True,enabled_when='loadpreviouscand == False'), - - + + Group(Group(Group(HGroup(Item(name='largerthan1',label='log[M > Msol]',springy=True), Item(name='normlargerthan1',label='M',width=-70,format_str='%.2e')),enabled_when='useintmult1==False'), @@ -74,21 +72,21 @@ class Candidates(HasTraits): Group(Item(name='moremassivethancand1',label='N',springy=True),enabled_when='useintmult1==True',springy=True)), Item(name='distancecut1',label='Within [Mpc]'),label='Exclusion Zone #1',show_border=True),enabled_when='loadpreviouscand == False'), - + Group(Group(Group(HGroup(Item(name='largerthan2',label='log[M > Msol]',springy=True), Item(name='normlargerthan2',label='M',width=-70,format_str='%.2e')),enabled_when='useintmult2==False'), HGroup(Item(name='useintmult2',label='Use N times candidates mass'), Group(Item(name='moremassivethancand2',label='N',springy=True),enabled_when='useintmult2==True',springy=True)), Item(name='distancecut2',label='Within [Mpc]'),label='Exclusion Zone #2',show_border=True),enabled_when='loadpreviouscand == False'), - + Group(Group(Group(HGroup(Item(name='largerthan3',label='log[M > Msol]',springy=True), Item(name='normlargerthan3',label='M',width=-70,format_str='%.2e')),enabled_when='useintmult3==False'), HGroup(Item(name='useintmult3',label='Use N times candidates mass'), Group(Item(name='moremassivethancand3',label='N',springy=True),enabled_when='useintmult3==True',springy=True)), Item(name='distancecut3',label='Within [Mpc]'),label='Exclusion Zone #3',show_border=True),enabled_when='loadpreviouscand == False'), - + HGroup(Item(name='writeoutopt',label='W/O?'), HGroup(Item(name='writeoutpath',label='Directory',springy=True), Item(name='writeoutname',label='Filename',springy=True),enabled_when='writeoutopt == True',springy=True), @@ -117,155 +115,112 @@ class Candidates(HasTraits): def _haloid_default(self): return [268422,242183,140666,241932,96624,121869,1930,173411,21047,233776] - #[190897,208737] - #,140666,28221,147419,28188,147273,78411,131988,19910] def _addhalo_button_fired(self): - if len(self.candidatearr[:,0]) != 1: - if int(self.haloidtoadd) in self.candidatearr[:,0] and int(self.haloidtoadd) not in self.haloid: - self.haloid.append(int(self.haloidtoadd)) - self.jobstatus = "Candidate added." - elif int(self.haloidtoadd) in self.haloid: - self.jobstatus = "Candidate already in list!" - else: - self.jobstatus = "Candidate not found!" + # Previously `!= 1`, which tested for the phantom zero row that used to + # seed the results array. An empty selection now has no rows at all. + if len(self.candidatearr) == 0: + self.jobstatus = "You need to load some candidates, go back." + elif int(self.haloidtoadd) in self.haloid: + self.jobstatus = "Candidate already in list!" + elif int(self.haloidtoadd) in self.candidatearr[:, 0]: + self.haloid.append(int(self.haloidtoadd)) + self.jobstatus = "Candidate added." else: - self.jobstatus = "You need to load some candidates, go back." + self.jobstatus = "Candidate not found!" - self.main.mergertreetab.haloidlist = self.haloid + self._publish_sample() + + def _publish_sample(self): + """Push the working halo sample out to every tab that consumes it.""" self.main.halofindtab.haloidlist = self.haloid self.main.mergertreetab.haloidlist = self.haloid - self.main.initstab.haloid = self.haloid + self.main.contaminationtab.haloidlist = self.haloid self.main.gadgetruntab.haloidlist = self.haloid + self.main.initstab.haloid = self.haloid def _haloidtoadd_changed(self): self.jobstatus = "Waiting for next addition." def _clear_button_fired(self): + # Previously this set `mergertreetab.initstab`, a stray attribute on the + # wrong object, so the ICs tab kept the cleared sample. It also missed + # the contamination and Gadget tabs entirely. self.haloid = [] - self.main.halofindtab.haloidlist = [] - self.main.mergertreetab.haloidlist = [] - self.main.mergertreetab.initstab = [] + self._publish_sample() + + def _zones(self): + """The three exclusion zones as configured on this tab.""" + zones = [] + for distance, multiple, absolute, use_multiple in ( + (self.distancecut1, self.moremassivethancand1, + self.normlargerthan1, self.useintmult1), + (self.distancecut2, self.moremassivethancand2, + self.normlargerthan2, self.useintmult2), + (self.distancecut3, self.moremassivethancand3, + self.normlargerthan3, self.useintmult3), + ): + if use_multiple: + zones.append(sci.ExclusionZone( + distance=distance, mass_multiple=multiple)) + else: + zones.append(sci.ExclusionZone( + distance=distance, mass=absolute)) + return zones + + def _plot_candidates(self): + """Draw the current candidate positions on the shared figure.""" + if hasattr(self, 'display_points'): + figure = self.main.display + figure.clear() + figure.add_subplot(111) + + ax = self.main.display.axes[0] + self.display_points = ax.plot(self.candidatearr[:, 3], + self.candidatearr[:, 4], + marker=self.main.markerstyle, + linestyle='none', + markersize=self.main.markersize, + color=self.main.markercolor, + markeredgecolor=self.main.markercolor) + ax.set_xlim(0, 100) + ax.set_ylim(0, 100) + ax.set_xlabel('x-pos [Mpc/h]') + ax.set_ylabel('y-pos [Mpc/h]') + wx.CallAfter(self.main.display.canvas.draw) def _getcandidates_button_fired(self): - hubble = 0.6711 - if self.loadpreviouscand == False: - halodata = RSDataReader.RSDataReader(self.halopath,self.snapnum,digits=2) - allhalos = halodata.get_hosts() - - MWcand = allhalos[np.logical_and(allhalos['mvir']/hubble>self.normlowermasscut, allhalos['mvir']/hubbleself.normlargerthan2] # HALOS LARGER THAN 7E12 - halos13 = allhalos[allhalos['mvir']/hubble>self.normlargerthan3] # HALOS LARGER THAN 7E13 - - self.nbetweenmasscut = len(MWcand) - self.nlargerthanlowermasscut = len(halos12) - self.nlargerthanuppermasscut = len(halos13) - - xpos12 = np.array(np.float64(halos12['posX'])) - ypos12 = np.array(np.float64(halos12['posY'])) - zpos12 = np.array(np.float64(halos12['posZ'])) - - xpos13 = np.array(np.float64(halos13['posX'])) - ypos13 = np.array(np.float64(halos13['posY'])) - zpos13 = np.array(np.float64(halos13['posZ'])) - - Ncandidates = 0 - if self.writeoutopt == True: - out = open(self.writeoutpath + self.writeoutname, 'w') - out.write('# ID M Rvir x y z\n') - - #xcand = [] - #ycand = [] - for i in xrange(0,len(MWcand)): - # CYCLE CANDIDATE i - xposi = np.array(np.float64(MWcand['posX']))[i] - yposi = np.array(np.float64(MWcand['posY']))[i] - zposi = np.array(np.float64(MWcand['posZ']))[i] - massi = np.array(np.float64(MWcand['mvir']))[i]/hubble - rviri = np.array(np.float64(MWcand['rvir']))[i] - idi = np.array(MWcand['id'])[i] - - # CALCULATE DISTANCE TO HALOS LARGER THAN 7E12 and 7E13 MSOL - - R12 = np.sqrt((xposi-xpos12)**2.+(yposi-ypos12)**2.+(zposi-zpos12)**2.)/hubble - R13 = np.sqrt((xposi-xpos13)**2.+(yposi-ypos13)**2.+(zposi-zpos13)**2.)/hubble - - # SELECT ALL HALOS LARGER THAN HALF THE SIZE OF THE CANDIDATE idi - - largerthanMW = allhalos[allhalos['mvir']/hubble >= self.moremassivethancand1*massi] - - # SINCE 0.5*massi INCLUDES idi, NEED TO REMOVE IT FROM X,Y,Z POS CALC SO MIN(R) != 0.0 - - idindex = np.where(largerthanMW['id'] != idi) - xtmp = np.array(np.float64(largerthanMW['posX'])) - ytmp = np.array(np.float64(largerthanMW['posY'])) - ztmp = np.array(np.float64(largerthanMW['posZ'])) - - xposMgtMW = xtmp[idindex[0]] - yposMgtMW = ytmp[idindex[0]] - zposMgtMW = ztmp[idindex[0]] - - RMgtMW = np.sqrt((xposi-xposMgtMW)**2.+(yposi-yposMgtMW)**2.+(zposi-zposMgtMW)**2.)/hubble - - # NO HALO LARGER THAN 7e13 CLOSER THAN 4 MPC - if R13.min() >= self.distancecut3: - # NO HALO LARGER THAN 7e12 CLOSER THAN 3 MPC - if R12.min() >= self.distancecut2: - # NO HALO HALF THE MASS OF CANDIDATE OR LARGER WITHIN 1.4 MPC - if RMgtMW.min() >= self.distancecut1: - self.candidatearr = np.vstack([self.candidatearr, [idi,massi,rviri,xposi,yposi,zposi]]) - Ncandidates += 1 - if self.writeoutopt == True: - out.write('%f %e %f %f %f %f \n' % - (int(idi),massi,rviri,xposi,yposi,zposi)) - - self.ntotalcandidates = Ncandidates - if Ncandidates > 0: - if hasattr(self, 'display_points'): - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - - ax = self.main.display.axes[0] - - self.display_points = ax.plot(self.candidatearr[:,3],self.candidatearr[:,4],marker=self.main.markerstyle,linestyle='none',markersize=self.main.markersize,color=self.main.markercolor,markeredgecolor=self.main.markercolor) - ax.set_xlim(0,100) - ax.set_ylim(0,100) - ax.set_xlabel('posX') - ax.set_ylabel('posY') - wx.CallAfter(self.main.display.canvas.draw) - - if self.writeoutopt == True: - out.close() - - elif self.loadpreviouscand == True: - cand = getcandidatelist(self.writeoutpath + self.writeoutname) - self.candidatearr = np.zeros(shape=(len(cand[:,0]),6)) - self.candidatearr[:,0] = cand[:,0] - self.candidatearr[:,1] = cand[:,1] - self.candidatearr[:,2] = cand[:,2] - self.candidatearr[:,3] = cand[:,3] - self.candidatearr[:,4] = cand[:,4] - self.candidatearr[:,5] = cand[:,5] - self.ntotalcandidates = len(cand[:,0]) - if hasattr(self, 'display_points'): - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - - ax = self.main.display.axes[0] - self.display_points = ax.plot(self.candidatearr[:,3],self.candidatearr[:,4], - marker=self.main.markerstyle, - linestyle='none', - markersize=self.main.markersize, - color=self.main.markercolor, - markeredgecolor=self.main.markercolor) - ax.set_xlim(0,100) - ax.set_ylim(0,100) - ax.set_xlabel('x-pos [Mpc/h]') - ax.set_ylabel('y-pos [Mpc/h]') - wx.CallAfter(self.main.display.canvas.draw) + hubble = cosmology.DEFAULT.h + if not self.loadpreviouscand: + halodata = RSDataReader.RSDataReader(self.halopath, self.snapnum, digits=2) + hosts = halodata.get_hosts() + + mass_range = (self.normlowermasscut, self.normuppermasscut) + zones = self._zones() + + tallies = sci.counts(hosts, mass_range, zones, hubble) + self.nbetweenmasscut = tallies['base_sample'] + self.nlargerthanlowermasscut = tallies.get('above_zone2', 0) + self.nlargerthanuppermasscut = tallies.get('above_zone3', 0) + + found = sci.select(hosts, mass_range, zones, hubble) + self.ntotalcandidates = len(found) + self.candidatearr = np.column_stack( + [found[name] for name in sci.COLUMNS] + ) if len(found) else np.zeros(shape=(0, 6)) + + if self.writeoutopt: + writers.write(os.path.join(self.writeoutpath, self.writeoutname), + sci.to_text(found)) + + if len(found): + self._plot_candidates() + + elif self.loadpreviouscand: + cand = getcandidatelist(os.path.join(self.writeoutpath, self.writeoutname)) + self.candidatearr = np.asarray(cand)[:, :6] + self.ntotalcandidates = len(self.candidatearr) + self._plot_candidates() def _largerthan1_changed(self): self.normlargerthan1 = 10**self.largerthan1 @@ -274,7 +229,7 @@ def _largerthan2_changed(self): self.normlargerthan2 = 10**self.largerthan2 def _largerthan3_changed(self): - self.normlargerthan3 = 10**self.largerthan3 + self.normlargerthan3 = 10**self.largerthan3 def _normlargerthan1_changed(self): self.largerthan1 = np.log10(self.normlargerthan1) @@ -290,20 +245,19 @@ def _uppermasscut_changed(self): def _normuppermasscut_changed(self): self.uppermasscut = np.log10(self.normuppermasscut) - + def _lowermasscut_changed(self): self.normlowermasscut = 10**self.lowermasscut def _normlowermasscut_changed(self): self.lowermasscut = np.log10(self.normlowermasscut) - + def __init__(self, main, **kwargs): HasTraits.__init__(self) self.main = main self.exclusionstr = 'Exclusion Zones: "no halo with mass M within distance R"' - self.hubble = 0.6711 - #self.haloid = [190897] + self.hubble = cosmology.DEFAULT.h self.lowermasscut = np.log10(7e11) self.uppermasscut = np.log10(7e12) @@ -311,7 +265,7 @@ def __init__(self, main, **kwargs): self.normlowermasscut = 10**self.lowermasscut self.normuppermasscut = 10**self.uppermasscut - self.candidatearr = np.zeros(shape=(1,6)) + self.candidatearr = np.zeros(shape=(0, 6)) self.distancecut1 = 1.4 self.distancecut2 = 3. @@ -328,8 +282,7 @@ def __init__(self, main, **kwargs): self.normlargerthan1 = 10**self.largerthan1 self.normlargerthan2 = 10**self.largerthan2 self.normlargerthan3 = 10**self.largerthan3 - #self.largerthan3 = np.log10(7e13) - + self.writeoutname = 'candidates.dat' self.writeoutpath = self.main.headertab.datamasterpath self.halopath = self.main.headertab.parentsimpath + 'RockstarData' diff --git a/src/cme/ui/common.py b/src/cme/ui/common.py new file mode 100644 index 0000000..5bc4537 --- /dev/null +++ b/src/cme/ui/common.py @@ -0,0 +1,148 @@ +"""Shared imports and the matplotlib-in-TraitsUI figure editor. + +Every tab does ``from Common import *``, so this module defines ``__all__`` +to make that surface explicit rather than leaking the whole transitive +import graph. +""" + +import os +import platform +import subprocess + +import modules.readhalos.readsubf as readsubf +import modules.readhalos.RSDataReaderv2 as RSDataReader +import modules.readsnapshots.readsnap as rs +import modules.readsnapshots.readsnapHDF5 as rsHD +import numpy as np +import wx +from enable.api import ColorTrait +from matplotlib import patches +from matplotlib.backends.backend_wx import NavigationToolbar2Wx +from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas +from matplotlib.figure import Figure +from modules.brendanlib.grifflib import ( + COM, + CorrectPos, + addsubtitle, + drawcircle, + getcandidatelist, + getcentext, + getlagrxyz, + placenormtext, +) +from traits.api import ( + Array, + Bool, + Button, + Directory, + Enum, + Float, + HasTraits, + Instance, + Int, + List, + Range, + Str, +) +from traitsui.api import ( + CheckListEditor, + Group, + Heading, + HGroup, + HSplit, + Item, + ListEditor, + Tabbed, + VGroup, + View, +) +from traitsui.basic_editor_factory import BasicEditorFactory +from traitsui.ui_editors.array_view_editor import ArrayViewEditor +from traitsui.wx.editor import Editor + +__all__ = [ + # Standard library and numerics + "os", + "platform", + "subprocess", + "np", + "wx", + # Matplotlib + "Figure", + "FigureCanvas", + "NavigationToolbar2Wx", + "patches", + # Traits + "Array", + "Bool", + "Button", + "ColorTrait", + "Directory", + "Enum", + "Float", + "HasTraits", + "Instance", + "Int", + "List", + "Range", + "Str", + # TraitsUI + "ArrayViewEditor", + "BasicEditorFactory", + "CheckListEditor", + "Editor", + "Group", + "HGroup", + "HSplit", + "Heading", + "Item", + "ListEditor", + "Tabbed", + "VGroup", + "View", + "MPLFigureEditor", + # Simulation I/O (external `modules` package) + "RSDataReader", + "readsubf", + "rs", + "rsHD", + "COM", + "CorrectPos", + "addsubtitle", + "drawcircle", + "getcandidatelist", + "getcentext", + "getlagrxyz", + "placenormtext", +] + + +class _MPLFigureEditor(Editor): + """Embeds a matplotlib canvas, with toolbar, in a TraitsUI view.""" + + scrollable = True + + def init(self, parent): + self.control = self._create_canvas(parent) + self.set_tooltip() + + def update_editor(self): + pass + + def _create_canvas(self, parent): + """Create the MPL canvas inside a panel that can hold the toolbar.""" + panel = wx.Panel(parent, -1, style=wx.CLIP_CHILDREN) + sizer = wx.BoxSizer(wx.VERTICAL) + panel.SetSizer(sizer) + + mpl_control = FigureCanvas(panel, -1, self.value) + sizer.Add(mpl_control, 1, wx.LEFT | wx.TOP | wx.GROW) + toolbar = NavigationToolbar2Wx(mpl_control) + sizer.Add(toolbar, 0, wx.EXPAND) + self.value.canvas.SetMinSize((10, 10)) + + return panel + + +class MPLFigureEditor(BasicEditorFactory): + klass = _MPLFigureEditor diff --git a/contam.py b/src/cme/ui/contamination.py similarity index 58% rename from contam.py rename to src/cme/ui/contamination.py index b82a823..114b73b 100644 --- a/contam.py +++ b/src/cme/ui/contamination.py @@ -1,11 +1,10 @@ -from Common import * -import modules.mergertrees.MTCatalogue as MT +from cme import cosmology, naming +from cme.ui.common import * + class Contamination(HasTraits): gadpath = Directory - checkexistence_button = Button("Check Existence") - createconfig_button = Button("Make and distribute gadget!") checkcontam_button = Button("Plot Radial Distribution") plothalodist_button = Button("Plot Heatmap") @@ -29,8 +28,6 @@ class Contamination(HasTraits): hostposy = Float() hostposz = Float() hostmass = Float() - vizexpz_button = Button("Inspect/Update List") - subscript_button = Button("Create Submission Script") includegroupsopt = Bool(False) xvar = Enum(['X-POS','Y-POS','Z-POS']) yvar = Enum(['X-POS','Y-POS','Z-POS']) @@ -43,7 +40,7 @@ class Contamination(HasTraits): yref = Float(50.) zref = Float(50.) - + view = View(Item('gadpath',label='Base Path'), Item(name='haloid',label='Halo ID'), @@ -79,26 +76,15 @@ class Contamination(HasTraits): Group(Item(name='haloidlist',show_label=False,style='readonly',editor=ListEditor(style='readonly',columns=6,rows=4)),label='Halo Sample',show_border=True)) def _checkcontam_button_fired(self): - self.foldername = 'H' + str(self.haloid) + \ - '_B' + str(self.boxtype.upper())[0] + \ - '_Z127' + \ - '_P' + str(self.padding) + \ - '_LN' + str(self.lmin) + \ - '_LX' + str(self.lmax) + \ - '_O' + str(self.overlap) + \ - '_NV' + str(self.nrvir) - - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs" \ - + "/groups_0" + str(self.snapnum) - hubble = 0.6711 - if os.path.exists(dircheck): - - tmppath = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs" - filepath = tmppath + "/snapdir_0" + str(self.snapnum) + "/snap_0" + str(self.snapnum) + self.foldername = naming.run_dir_name(halo_id=self.haloid, **self._run_kwargs()) + + hubble = cosmology.DEFAULT.h + if os.path.exists(self.groups_dir()): + + tmppath = self.outputs_dir() s = readsubf.subfind_catalog(tmppath, self.snapnum) #ids = readsubf.subf_ids(tmppath, self.snapnum, 0, 0, read_all=1) mgroup = s.group_m_mean200*10**10/hubble - rvirgroup = s.group_r_mean200 xposgroup = s.group_pos[:,0] yposgroup = s.group_pos[:,1] zposgroup = s.group_pos[:,2] @@ -121,11 +107,6 @@ def _checkcontam_button_fired(self): zposhost = zposgroup[cond] mvir = mgroup[cond] - rvirsub = s.sub_halfmassrad - xpossub = s.sub_pos[:,0] - ypossub = s.sub_pos[:,1] - zpossub = s.sub_pos[:,2] - mhalfsub = s.sub_mass*10**10/hubble #print s.sub_grnr titlestr = "Type: " + str(self.boxtype[0]) + "\n" + \ @@ -133,17 +114,7 @@ def _checkcontam_button_fired(self): "n*rvir(z=0): " + str(self.nrvir) + "\n" + \ "level max: " + str(self.lmax) + "\n" - - R = np.sqrt((self.xref-xpossub)**2+(self.yref-ypossub)**2+(self.zref-zpossub)**2) - sortindex = np.argsort(R) - Rsorted = R[sortindex] - Msorted = mhalfsub[sortindex] - Groupsorted = s.sub_grnr[sortindex] - masscon = (Msorted > self.lowermassrange) & (Msorted < self.uppermassrange) & (Rsorted < self.deltar) - #print Groupsorted[masscon] - #print Rsorted[masscon] - #print Msorted[masscon] if len(xposhost) > 1: self.datastatus = "Too many possible hosts found, be more strict." @@ -163,13 +134,13 @@ def _checkcontam_button_fired(self): self.hostposy = float(yposhost) self.hostposz = float(zposhost) self.hostmass = float(mvir) - + R = np.sqrt((xposhost-xposgroup)**2+(yposhost-yposgroup)**2+(zposhost-zposgroup)**2) sortindex = np.argsort(R) Rsorted = R[sortindex] contamNR = np.cumsum(s.group_contamination_count[sortindex]) contamMR = np.cumsum(s.group_contamination_mass[sortindex]*10**10) - + figure = self.main.display figure.clear() ax = figure.add_subplot(111) @@ -179,12 +150,12 @@ def _checkcontam_button_fired(self): ax.plot(Rsorted,np.log10(np.array(contamNR)),linestyle='-',color='b',linewidth=2) ax.set_xlim([0,7]) ax.set_ylim([0,5]) - + ax.set_ylabel(r'$\mathrm{\Sigma\ log_{10}\ N_{CP}}$', color='b',fontsize=14) - + for tl in ax.get_yticklabels(): tl.set_color('b') - + axb = ax.twinx() axb.tick_params(axis='both', which='major', labelsize=12) axb.set_ylim([8,14]) @@ -192,96 +163,72 @@ def _checkcontam_button_fired(self): axb.set_ylim([8,14]) for tl in axb.get_yticklabels(): tl.set_color('r') - + axb.set_ylabel(r'$\mathrm{log_{10}\ \Sigma\ M_{CP}\ [M_\odot/h]}$', color='r',fontsize=14) ax.set_xlabel(r'$\mathrm{R_{FOF}\ [Mpc/h]}$',fontsize=14) #ax.set_ylabel(r'$\mathrm{y-pos\ [Mpc/h]}$') #ax.set_xlabel(r'$\mathrm{y-pos\ [Mpc/h]}$') - + wx.CallAfter(self.main.display.canvas.draw) else: self.datastatus = "Data not found, not plotted." + def _run_kwargs(self): + """The parameter set identifying the resimulation currently selected.""" + return dict(region=self.boxtype, + padding=self.padding, + levelmin=self.lmin, + levelmax=self.lmax, + overlap=self.overlap, + nrvir=self.nrvir, + zinit=self.zinit) + + def outputs_dir(self): + """Directory Gadget wrote this run's snapshots and catalogues into.""" + return naming.outputs_dir(self.gadpath, self.haloid, **self._run_kwargs()) + + def groups_dir(self): + """Directory holding the FOF/SUBFIND catalogue for the chosen snapshot.""" + return naming.groups_dir(self.outputs_dir(), self.snapnum) + + def _refresh(self, replot=False): + """Recompute the run name and whether its group catalogue exists. + + Every parameter change funnels through here, so the displayed folder + name and the enabled state of the plot buttons cannot drift apart. + """ + self.foldername = naming.run_dir_name(halo_id=self.haloid, **self._run_kwargs()) + self.makeactive = os.path.exists(self.groups_dir()) + if replot and self.makeactive: + self._checkcontam_button_fired() + def _haloid_changed(self): - #self.checkcontam_button_fired() - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive = True - else: - self.makeactive = False - + self._refresh() + def _boxtype_changed(self): - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive = True - else: - self.makeactive = False - + self._refresh() + def _nrvir_changed(self): - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self._checkcontam_button_fired() - self.makeactive = True - else: - self.makeactive = False - + self._refresh(replot=True) + def _deltar_changed(self): - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive = True - self._checkcontam_button_fired() - else: - self.makeactive = False + self._refresh(replot=True) def _lmin_changed(self): - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive = True - self._checkcontam_button_fired() - else: - self.makeactive = False - + self._refresh(replot=True) + def _lmax_changed(self): - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive = True - self._checkcontam_button_fired() - else: - self.makeactive = False - + self._refresh(replot=True) + def _padding_changed(self): - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive = True - self._checkcontam_button_fired() - else: - self.makeactive = False - + self._refresh(replot=True) + def _overlap_changed(self): - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive == True - self._checkcontam_button_fired() - else: - self.makeactive == False - + self._refresh(replot=True) + def _snapnum_changed(self): - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive == True - self._checkcontam_button_fired() - else: - self.makeactive == False + self._refresh(replot=True) def _xvar_changed(self): if self.xvar == self.yvar: @@ -296,29 +243,18 @@ def _yvar_changed(self): self.xymatch = False def _plothalodist_button_fired(self): - self.foldername = 'H' + str(self.haloid) + \ - '_B' + str(self.boxtype.upper())[0] + \ - '_Z127' + \ - '_P' + str(self.padding) + \ - '_LN' + str(self.lmin) + \ - '_LX' + str(self.lmax) + \ - '_O' + str(self.overlap) + \ - '_NV' + str(self.nrvir) - + self.foldername = naming.run_dir_name(halo_id=self.haloid, **self._run_kwargs()) + figure = self.main.display figure.clear() ax = figure.add_subplot(111) ax = self.main.display.axes[0] - - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs" \ - + "/groups_0" + str(self.snapnum) - - hubble = 0.6711 - - if os.path.exists(dircheck): + + hubble = cosmology.DEFAULT.h + + if os.path.exists(self.groups_dir()): self.datastatus = "Data exists, plotted." - tmppath = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs" - filepath = tmppath + "/snapdir_0" + str(self.snapnum) + "/snap_0" + str(self.snapnum) + tmppath = self.outputs_dir() s = readsubf.subfind_catalog(tmppath, self.snapnum) #ids = readsubf.subf_ids(tmppath, self.snapnum, 0, 0, read_all=1) mgroup = s.group_m_mean200*10**10/hubble @@ -326,7 +262,7 @@ def _plothalodist_button_fired(self): xposmgroup = s.group_pos[:,0] yposmgroup = s.group_pos[:,1] zposmgroup = s.group_pos[:,2] - + if self.weightopt == 'count': weights = s.group_contamination_count @@ -361,10 +297,10 @@ def _plothalodist_button_fired(self): heatmap, xedges, yedges = np.histogram2d(xvar, yvar, bins=self.nbins,weights=weights) extent = [xedges[0], xedges[-1], yedges[0], yedges[-1]] - - if self.includegroupsopt == True: - xcirc,ycirc = drawcircle(xvar[mgroup > 10**self.lowerlogmassbound],yvar[mgroup > 10**self.lowerlogmassbound],rvirmgroup[mgroup > 10**self.lowerlogmassbound]) - ax.plot(xcirc,ycirc,'k-',linewidth=2,alpha=0.3) + + if self.includegroupsopt: + xcirc,ycirc = drawcircle(xvar[mgroup > 10**self.lowerlogmassbound],yvar[mgroup > 10**self.lowerlogmassbound],rvirmgroup[mgroup > 10**self.lowerlogmassbound]) + ax.plot(xcirc,ycirc,'k-',linewidth=2,alpha=0.3) heatmap = np.flipud(np.rot90(heatmap)) sc1 = ax.imshow(np.log10(heatmap),extent = extent,cmap = 'jet', origin='lower') @@ -380,13 +316,11 @@ def _plothalodist_button_fired(self): def _plotmayavi_button_fired(self): self.main.scene.mlab.clf(figure=self.main.scene.mayavi_scene) - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - hubble = 0.6711 - if os.path.exists(dircheck): + self.foldername = naming.run_dir_name(halo_id=self.haloid, **self._run_kwargs()) + hubble = cosmology.DEFAULT.h + if os.path.exists(self.groups_dir()): self.datastatus = "Data exists, plotted." - tmppath = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs" - filepath = tmppath + "/snapdir_0" + str(self.snapnum) + "/snap_0" + str(self.snapnum) + tmppath = self.outputs_dir() s = readsubf.subfind_catalog(tmppath, self.snapnum) mgroup = s.group_m_mean200*10**10/hubble @@ -421,13 +355,8 @@ def __init__(self, main, **kwargs): self.haloidlist = self.main.candidatestab.haloid self.gadpath = self.main.headertab.datamasterpath - - self.foldername = 'H' + str(self.haloid) + '_B' + str(self.boxtype.upper())[0] + '_Z127' + '_P' + str(self.padding) + '_LN' + str(self.lmin) + '_LX' + str(self.lmax) + '_O' + str(self.overlap) + '_NV' + str(self.nrvir) - dircheck = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/outputs/groups_0" + str(self.snapnum) - if os.path.exists(dircheck): - self.makeactive = True - else: - self.makeactive = False + + self._refresh() + - diff --git a/src/cme/ui/gadget.py b/src/cme/ui/gadget.py new file mode 100644 index 0000000..ca0b633 --- /dev/null +++ b/src/cme/ui/gadget.py @@ -0,0 +1,748 @@ +import shutil + +from cme import cosmology, naming, shell, writers +from cme.ui.common import * +from cme.writers import gadget as gadgetwriter +from cme.writers import submit + +#: Driver script the tab writes and then runs. +DRIVER_SCRIPT = "rungadget.sh" + +class GadgetRun(HasTraits): + #clusteropt = List() + + PBSncores = Range(0,64,8) + PBSnnodes = Range(0,10,8) + username = Str() + PBSqueue = Str("default") + outfile = Str("OUTPUT") + errorfile = Str("ERROR") + PBSextraflags = Str("#PBS -m ae ") + PBSextralines = Str() + PBSexecute = Str() + compilegadopt = Bool(False) + InitCondFile = Str() + OutputDir = Str() + EnergyFile = Str() + InfoFile = Str() + TimingsFile = Str() + CpuFile = Str() + RestartFile = Str() + SnapshotFileBase = Str() + OutputListFilename = Str() + TimebinFile = Str() + ICFormat = Int() + SnapFormat = Int() + TimeLimitCPU = Int() + CpuTimeBetRestartFile = Int() + ResubmitOn = Int() + ResubmitCommand = Str() + MaxMemSize = Int() + PartAllocFactor = Float() + BufferSize = Int() + TimeBegin = Float() + TimeMax = Float() + ComovingIntegrationOn = Int() + PeriodicBoundariesOn = Int() + CoolingOn = Int() + StarformationOn = Int() + Omega0 = Float() + OmegaLambda = Float() + OmegaBaryon = Float() + HubbleParam = Float() + BoxSize = Float() + OutputListOn = Int() + TimeBetSnapshot = Float() + TimeOfFirstSnapshot = Float() + TimeBetStatistics = Float() + NumFilesPerSnapshot = Int() + NumFilesWrittenInParallel = Int() + TypeOfTimestepCriterion = Int() + ErrTolIntAccuracy = Float() + CourantFac = Float() + MaxRMSDisplacementFac = Float() + MaxSizeTimestep = Float() + MinSizeTimestep = Float() + InitGasTemp = Float() + MinGasTemp = Float() + TypeOfOpeningCriterion = Int() + ErrTolTheta = Float() + ErrTolForceAcc = Float() + TreeDomainUpdateFrequency = Float() + DesNumNgb = Int() + MaxNumNgbDeviation = Int() + UnitLength_in_cm = Float() + UnitMass_in_g = Float() + UnitVelocity_in_cm_per_s = Float() + GravityConstantInternal = Int() + MinGasHsmlFractional = Float() + SofteningGas = Float() + SofteningHalo = Float() + SofteningDisk = Float() + SofteningBulge = Float() + SofteningStars = Float() + SofteningBndry = Float() + SofteningGasMaxPhys = Float() + SofteningHaloMaxPhys = Float() + SofteningDiskMaxPhys = Float() + SofteningBulgeMaxPhys = Float() + SofteningStarsMaxPhys = Float() + SofteningBndryMaxPhys = Float() + ArtBulkViscConst = Float() + levelmaxuse = Int(12) + ErrTolThetaSubfind = Float + DesLinkNgb = Int + ExpansionListArr = Array + + PMGRID = Int(512) + ENABLE_SUBFIND = Bool(False) + FOF_SECONDARY_LINK_TYPES = Str() + + gadpath = Directory + checkexistence_button = Button("Check Existence") + padding = List(editor = CheckListEditor(values = ['5','6','7','8','9','10'],cols=6) ) + lmin = Enum(['7','8','9','10','11','12','13','14','15']) + lmax = Enum(['7','8','9','10','11','12','13','14','15']) + overlap = List(editor = CheckListEditor(values = ['1','2','3','4','5','6'],cols=6) ) + nrvir = List(editor = CheckListEditor(values = ['1','2','3','4','5','6','7','8','9'],cols=11)) + cosmologylist = List( editor = CheckListEditor(values = ['WMAP1','WMAP3','WMAP5','WMAP7','WMAP9','PLANCK'],cols = 6) ) + boxtype = List( editor = CheckListEditor(values = ['ellipsoid','box'],cols=2)) + haloid = Int(190897) + haloidlist = List(Int) + zinit = Range(0,127,127) + redshifti = Float() + expfacti = Float() + nintervals = Enum(['32','64','128','258','512','1024']) + snapshotlist = Str() + alloutputopt = Bool(True) + vizexpz_button = Button("Inspect/Update List") + subscript_button = Button("Run Script") + submitjob = Bool(False) + + SLURMcores = Range(64,512,64) + SLURMtime = Int(5000) + SLURMmemory = Int(3500) + # Was an Enum of three Harvard queue names, which cannot hold the queue a + # different site's profile specifies. + SLURMqueue = Str("general") + emailaddress = Str() + subtype = Enum(['SLURM','PBS']) + SLURMexecute = Str() + + #: Environment setup run before building Gadget on a SLURM machine. + #: Previously the hardcoded lines `unloadmods` and `loadgadget`. + modulelines = Str("unloadmods\nloadgadget") + + view = View(Group(Group(Group( + Item('gadpath',label='Base Path'), + Item(name='haloid',label='Halo ID'), + Item(name='nrvir',label='Nvir',style='custom'), + HGroup(Item(name='boxtype',label='Box Type',style='custom'),Item(name='lmin',label='LMIN'),Item(name='lmax',label='LMAX'),Item(name='zinit',label='zinit')), + Item(name='cosmologylist',label='Cosmology',style='custom'), + Item(name='padding',label='Padding',style='custom'), + Item(name='overlap',label='Overlap',style='custom')), + Group(Item('checkexistence_button',show_label=False)), + Group(Item(name='haloidlist',show_label=False,style='readonly',editor=ListEditor(style='readonly',columns=6,rows=4)),label='Halo Sample',show_border=True)),label='Existence'), + + Group( + Item('InitCondFile',label='InitCondFile'), + Item('OutputDir',label='OutputDir'), + Item('EnergyFile',label='EnergyFile'), + Item('InfoFile' ,label='InfoFile '), + Item('TimingsFile',label='TimingsFile'), + Item('CpuFile',label='CpuFile'), + Item('RestartFile',label='RestartFile'), + Item('SnapshotFileBase',label='SnapshotFileBase'), + Item('OutputListFilename',label='OutputListFilename'), + Item('TimebinFile',label='TimebinFile'), + Item('ICFormat',label='ICFormat'), + Item('SnapFormat',label='SnapFormat'), + Item('TimeLimitCPU',label='TimeLimitCPU'), + Item('CpuTimeBetRestartFile',label='CpuTimeBetRestartFile'), + Item('ResubmitOn',label='ResubmitOn'), + Item('ResubmitCommand',label='ResubmitCommand'), + Item('MaxMemSize',label='MaxMemSize'), + Item('PartAllocFactor',label='PartAllocFactor'), + Item('BufferSize',label='BufferSize'), + Item('TimeBegin',label='TimeBegin'), + Item('TimeMax',label='TimeMax'),label='Format'), + + Group(Item('ComovingIntegrationOn',label='ComovingIntegrationOn'), + Item('PeriodicBoundariesOn',label='PeriodicBoundariesOn'), + Item('CoolingOn',label='CoolingOn'), + Item('StarformationOn',label='StarformationOn'), + Item('Omega0',label='Omega0'), + Item('OmegaLambda',label='OmegaLambda'), + Item('OmegaBaryon',label='OmegaBaryon'), + Item('HubbleParam',label='HubbleParam'), + Item('BoxSize',label='BoxSize'), + Item('OutputListOn',label='OutputListOn'), + Item('TimeBetSnapshot',label='TimeBetSnapshot'), + Item('TimeOfFirstSnapshot',label='TimeOfFirstSnapshot'), + Item('TimeBetStatistics',label='TimeBetStatistics'), + Item('NumFilesPerSnapshot',label='NumFilesPerSnapshot'), + Item('NumFilesWrittenInParallel',label='NumFilesWrittenInParallel'), + Item('TypeOfTimestepCriterion',label='TypeOfTimestepCriterion'), + Item('ErrTolIntAccuracy',label='ErrTolIntAccuracy'), + Item('CourantFac',label='CourantFac'), + Item('MaxRMSDisplacementFac',label='MaxRMSDisplacementFac'), + Item('MaxSizeTimestep',label='MaxSizeTimestep'), + Item('MinSizeTimestep',label='MinSizeTimestep'), + Item('InitGasTemp',label='InitGasTemp'), + Item('MinGasTemp',label='MinGasTemp'),label='Cosmology & Timestep'), + + Group(Item('TypeOfOpeningCriterion',label='TypeOfOpeningCriterion'), + HGroup(Item('ErrTolTheta',label='ErrTolTheta'), + Item('ErrTolForceAcc',label='ErrTolForceAcc')), + Item('TreeDomainUpdateFrequency',label='TreeDomainUpdateFrequency'), + Item('DesNumNgb',label='DesNumNgb'), + Item('MaxNumNgbDeviation',label='MaxNumNgbDeviation'), + Item('UnitLength_in_cm',label='UnitLength_in_cm'), + Item('UnitMass_in_g',label='UnitMass_in_g'), + Item('UnitVelocity_in_cm_per_s',label='UnitVelocity_in_cm_per_s'), + Item('GravityConstantInternal',label='GravityConstantInternal'), + + Item('MinGasHsmlFractional',label='MinGasHsmlFractional'), + Item('SofteningGas',label='SofteningGas'), + Item('SofteningHalo',label='SofteningHalo'), + Item('SofteningDisk',label='SofteningDisk'), + Item('SofteningBulge',label='SofteningBulge'), + Item('SofteningStars',label='SofteningStars'), + Item('SofteningBndry',label='SofteningBndry'), + Item('SofteningGasMaxPhys',label='SofteningGasMaxPhys'), + Item('SofteningHaloMaxPhys',label='SofteningHaloMaxPhys'), + Item('SofteningDiskMaxPhys',label='SofteningDiskMaxPhys'), + Item('SofteningBulgeMaxPhys',label='SofteningBulgeMaxPhys'), + Item('SofteningStarsMaxPhys',label='SofteningStarsMaxPhys'), + Item('SofteningBndryMaxPhys',label='SofteningBndryMaxPhys'), + Item('ArtBulkViscConst' ,label='ArtBulkViscConst '),label='Softening'), + + Group(HGroup(Item('redshifti',label='Starting Output Redshift'),Item('expfacti',label='Expansion Factor')), + Item('nintervals',label='Number Of Outputs',style='custom'), + HGroup(Item('alloutputopt',label='Output Every Snapshot?'),Group(Item('snapshotlist',label='Only Specific Snapshots (csv)',springy=True),springy=True,enabled_when='alloutputopt == False')), + Group(Item('vizexpz_button',show_label=False),enabled_when='haloid in haloidlist'), + Heading('ExpansionList Output (last two columns)'), + Item('ExpansionListArr',show_label = False, + editor= ArrayViewEditor(titles = [ 'snapshot','redshift','exp. fact.', ' I/O' ], + format = '%2.5e', + show_index = False + )),label='Exp.'), + + Group(HGroup(Item('PMGRID',label='PMGRID'), + Item('ENABLE_SUBFIND',label='Subfind'), + Group(Item('FOF_SECONDARY_LINK_TYPES',label='FOF_SECONDARY_LINK_TYPES'),enabled_when='ENABLE_SUBFIND==True',springy=True)), + Item('_'), + Item('clusteropt',label='Cluster',style='readonly'), + Item('subtype',label='Submission Type'), + Item('emailaddress',label='Email'), + Item('outfile',label='Output File'), + Item('errorfile',label='Error File'), + Item('username',label='Username'), + Group( + Item('PBSncores',label='# Cores'), + Item('PBSnnodes',label='# Nodes'), + Item('PBSqueue',label='Queue'), + Item('PBSextraflags',label='Extra Flag'), + Item('PBSextralines',label='Extra Lines'), + Item('PBSexecute',label='Execute Line'),show_border=True,enabled_when="subtype=='PBS'"), + + Group( + Item('SLURMcores',label='# Cores'), + Item('SLURMtime',label='Runtime'), + Item('SLURMmemory',label='Memory'), + Item('SLURMqueue',label='Queue'), + Item('SLURMexecute',label='Execute Line'),show_border=True,enabled_when="subtype=='SLURM'"), + + HGroup(Item('compilegadopt',label='(Re-)Compile Gadget?'),Item('submitjob',label='Submit Job?'),Item('subscript_button',show_label=False)), + label='Write & Submit Job') + ) + + def _subscript_button_fired(self): + + # Accumulated and written once, so the handle cannot leak on an + # exception part-way through the sweep. + lines = [] + lines.append("#!/bin/bash \n") + + # The Gadget source tree comes from the Home tab, which derives it from the + # selected cluster profile. This used to be two hardcoded absolute paths + # under a specific user's home directory, chosen by scheduler type. + gadgetpath = str(self.main.headertab.gadgetpath) + gadgetfilepath = os.path.dirname(gadgetpath.rstrip(os.sep)) + + if self.subtype == "SLURM": + for line in self.modulelines.splitlines(): + lines.append(line + "\n") + + lines.append("cd " + gadgetpath + "\n") + + lines.append("make clean\n") + lines.append("make -j 8\n") + + for boxtypei in self.boxtype: + for nrviri in self.nrvir: + for paddingi in self.padding: + for overlapi in self.overlap: + run = dict(region=boxtypei, padding=paddingi, + levelmin=self.lmin, levelmax=self.lmax, + overlap=overlapi, nrvir=nrviri, zinit=self.zinit) + jobname = naming.job_name(halo_id=self.haloid, region=boxtypei, + padding=paddingi, levelmax=self.lmax, + nrvir=nrviri) + filepath = naming.run_dir(self.gadpath, self.haloid, **run) + if not os.path.exists(filepath): + print("PATH NOT FOUND:",filepath) + else: + print("PATH FOUND:",filepath) + self.levelmaxuse = int(self.lmax) + # Mirror the selected cosmology onto the displayed + # traits. This used to unpack cosmoconstant(), which + # put H0 into HubbleParam -- a field the parameter + # file writes as the dimensionless h. + cosmo = cosmology.get(self.cosmologylist[0]) + self.Omega0 = cosmo.omega_m + self.OmegaLambda = cosmo.omega_l + self.HubbleParam = cosmo.h + # Written directly into the run directory. The + # original wrote into the current working directory + # and then shelled out to `mv`. + self.makeparam(os.path.join(filepath, 'param.txt'), + filepath, includesub=False) + if self.ENABLE_SUBFIND: + self.makeparam(os.path.join(filepath, 'param_sub.txt'), + filepath, includesub=True) + + os.makedirs(os.path.join(filepath, "outputs"), + exist_ok=True) + + scriptpath = os.path.join( + filepath, + "runscript_sub" if self.ENABLE_SUBFIND else "runscript") + + + if self.subtype == "SLURM": + script = submit.slurm_script( + job_name=jobname, + work_dir=filepath, + execute=self.execute_command(), + cores=self.SLURMcores, + minutes=self.SLURMtime, + memory_mb=self.SLURMmemory, + queue=self.SLURMqueue, + email=self.emailaddress) + else: + script = submit.pbs_script( + job_name=jobname, + work_dir=filepath, + execute=self.execute_command(), + cores_per_node=self.PBSncores, + nodes=self.PBSnnodes, + queue=self.PBSqueue, + email=self.emailaddress, + extra_flags=self.PBSextraflags, + extra_lines=self.PBSextralines) + writers.write(scriptpath, script) + + if self.compilegadopt: + name = ("Config_sub.sh" if self.ENABLE_SUBFIND + else "Config.sh") + self.install_config_sh( + os.path.join(filepath, name), + os.path.join(gadgetfilepath, "P-Gadget3", + "Config.sh")) + #try: + lines.append("cd " + str(filepath) + "\n") + lines.append("rm " + str(filepath) + "/*.e*\n") + lines.append("rm " + str(filepath) + "/*.o*\n") + #lines.append("rm " + str(filepath) + "/ExpansionList_64\n") + + if self.ENABLE_SUBFIND: + lines.append("\n") + lines.append("rm P-Gadget3_sub\n") + lines.append("rm P-Gadget3_Sub\n") + lines.append("rm ERRORsub\n") + lines.append("rm OUTPUTsub\n") + lines.append("cp " + gadgetfilepath + "P-Gadget3/P-Gadget3 " + filepath + "/P-Gadget3_sub\n") + if self.submitjob: + lines.append("qsub runscript_sub \n") + else: + lines.append("\n") + lines.append("rm P-Gadget3\n") + lines.append("rm ERROR\n") + lines.append("rm OUTPUT\n") + lines.append("cp " + gadgetfilepath + "P-Gadget3/P-Gadget3 " + filepath + "/P-Gadget3\n") + + if self.submitjob: + if self.subtype == "PBS": + lines.append("qsub runscript \n") + elif self.subtype == "SLURM": + lines.append("sbatch runscript \n") + + writers.write(DRIVER_SCRIPT, "".join(lines)) + shell.run(["bash", DRIVER_SCRIPT]) + print("SUBMITTED JOBS!") + + #: Number of leading lines of the upstream Config.sh that this tool replaces. + #: Everything below is the project-specific tail and is preserved verbatim. + CONFIG_PREAMBLE_LINES = 95 + + def install_config_sh(self, generated, upstream): + """Splice a generated Config.sh onto the tail of the upstream one. + + The original ran `tail -n+96 > bottomConfig`, `cat bottomConfig + >> `, then `cp ` through a shell, with + both paths interpolated. Doing it in Python removes the injection surface + and the temporary file, and lets a missing upstream file be reported + rather than producing an empty splice. + """ + if not os.path.exists(upstream): + print("Config.sh not found, not splicing:", upstream) + return + + with open(upstream) as f: + tail = f.readlines()[self.CONFIG_PREAMBLE_LINES:] + + with open(generated, "a") as f: + f.writelines(tail) + + shutil.copyfile(generated, upstream) + + def _refresh_softenings(self): + """Mirror the derived softening lengths onto the displayed traits.""" + for name, value in gadgetwriter.softenings(self.BoxSize, self.lmax).items(): + setattr(self, "Softening" + name, value) + setattr(self, "Softening" + name + "MaxPhys", value) + + def execute_command(self, scheduler=None): + """The mpirun line for the given scheduler, or for the selected one.""" + scheduler = (scheduler or self.subtype).upper() + ranks = (int(self.SLURMcores) if scheduler == "SLURM" + else int(self.PBSncores) * int(self.PBSnnodes)) + return submit.mpirun_command( + ranks=ranks, + include_subfind=self.ENABLE_SUBFIND, + out=self.outfile, + err=self.errorfile) + + def _refresh_execute(self): + """Keep both displayed execute lines in step with the current settings.""" + self.PBSexecute = self.execute_command("PBS") + self.SLURMexecute = self.execute_command("SLURM") + + # Any of these invalidate both execute lines. + _ENABLE_SUBFIND_changed = _refresh_execute + _PBSncores_changed = _refresh_execute + _PBSnnodes_changed = _refresh_execute + _SLURMcores_changed = _refresh_execute + _errorfile_changed = _refresh_execute + _outfile_changed = _refresh_execute + + def _vizexpz_button_fired(self): + figure = self.main.display + figure.clear() + ax = figure.add_subplot(111) + ax = self.main.display.axes[0] + + snapshots = range(0,int(self.nintervals)) + expfact = np.linspace(self.expfacti,1,int(self.nintervals)) + + ax.set_xlabel('Snapshot') + ax.set_ylabel('Expansion Factor') + redall = np.float64(1./expfact - 1) + + if self.alloutputopt: + self.ExpansionListArr = np.zeros(shape=(len(snapshots),4)) + self.ExpansionListArr[:,0] = snapshots + self.ExpansionListArr[:,1] = redall + self.ExpansionListArr[:,2] = expfact + self.ExpansionListArr[:,3] = np.array(len(expfact)*[1]) + self.display_points = ax.plot(snapshots,expfact,'bo') + + else: + listin = str(self.snapshotlist) + snapshotin = np.array(listin.split(',')) + listcomp = np.array(self.ExpansionListArr[:,0]) + self.ExpansionListArr[:,3] = np.array(len(expfact)*[0]) + for i in range(0,len(listcomp)): + for j in range(0,len(snapshotin)): + if int(listcomp[i]) == int(snapshotin[j]): + self.ExpansionListArr[i,3] = 1 + + self.display_points = ax.plot(snapshots,expfact,'b-') + wx.CallAfter(self.main.display.canvas.draw) + + # listin = str(self.specificredshift) + # redshiftlist =listin.split(',') + # io1 = [] + # io2 = [] + # io3 = [] + # io4 = [] + # for redshift in redshiftlist: + # redshift = float(redshift) + # exptmp = np.float64(1./(1+redshift)) + # foundexp = min(range(len(expfact)), key=lambda i: abs(np.float64(expfact[i])-exptmp)) + # ax.plot(snapshots[foundexp],expfact[foundexp],'bo') + # io1.append(snapshots[foundexp]) + # io2.append(float(redshift)) + # io3.append(expfact[foundexp]) + # io4.append(1) + + #self.ExpansionListArr = [] + # self.ExpansionListArr = np.zeros(shape=(len(io1),4)) + # self.ExpansionListArr[:,0] = io1 + # self.ExpansionListArr[:,1] = io2 + # self.ExpansionListArr[:,2] = io3 + # self.ExpansionListArr[:,3] = io4 + + + def _expfacti_changed(self): + self.redshifti = 1./self.expfacti - 1 + + def _redshifti_changed(self): + self.expfacti = 1./(1 + self.redshifti) + + def _BoxSize_changed(self): + self._refresh_softenings() + + def _lmax_changed(self): + self.PMGRID = gadgetwriter.pmgrid_for_levelmax(self.lmax) + self._refresh_softenings() + + def write_config_sh(self, filename): + """Emit a P-Gadget3 Config.sh for the current compile-time settings.""" + return writers.write(filename, gadgetwriter.config_sh( + pmgrid=self.PMGRID, + include_subfind=self.ENABLE_SUBFIND, + fof_secondary_link_types=self.FOF_SECONDARY_LINK_TYPES)) + + def makeparam(self, filename, ext, includesub): + """Emit a Gadget parameter file plus its ExpansionList, into ``ext``.""" + writers.write( + os.path.join(ext, self.OutputListFilename), + gadgetwriter.expansion_list( + (row[2], row[3]) for row in self.ExpansionListArr)) + + return writers.write(filename, gadgetwriter.param_file( + cosmo=cosmology.get(self.cosmologylist[0]), + boxsize=self.BoxSize, + levelmax=self.lmax, + init_cond_file=self.InitCondFile, + output_dir=self.OutputDir, + energy_file=self.EnergyFile, + info_file=self.InfoFile, + timings_file=self.TimingsFile, + cpu_file=self.CpuFile, + restart_file=self.RestartFile, + snapshot_file_base=self.SnapshotFileBase, + output_list_filename=self.OutputListFilename, + timebin_file=self.TimebinFile, + ic_format=self.ICFormat, + snap_format=self.SnapFormat, + time_limit_cpu=self.TimeLimitCPU, + cpu_time_bet_restart_file=self.CpuTimeBetRestartFile, + resubmit_on=self.ResubmitOn, + resubmit_command=self.ResubmitCommand, + max_mem_size=self.MaxMemSize, + part_alloc_factor=self.PartAllocFactor, + buffer_size=self.BufferSize, + time_begin=self.TimeBegin, + time_max=self.TimeMax, + comoving_integration_on=self.ComovingIntegrationOn, + periodic_boundaries_on=self.PeriodicBoundariesOn, + cooling_on=self.CoolingOn, + star_formation_on=self.StarformationOn, + omega_baryon=self.OmegaBaryon, + output_list_on=self.OutputListOn, + time_bet_snapshot=self.TimeBetSnapshot, + time_of_first_snapshot=self.TimeOfFirstSnapshot, + time_bet_statistics=self.TimeBetStatistics, + num_files_per_snapshot=self.NumFilesPerSnapshot, + num_files_written_in_parallel=self.NumFilesWrittenInParallel, + type_of_timestep_criterion=self.TypeOfTimestepCriterion, + err_tol_int_accuracy=self.ErrTolIntAccuracy, + courant_fac=self.CourantFac, + max_rms_displacement_fac=self.MaxRMSDisplacementFac, + max_size_timestep=self.MaxSizeTimestep, + min_size_timestep=self.MinSizeTimestep, + init_gas_temp=self.InitGasTemp, + min_gas_temp=self.MinGasTemp, + type_of_opening_criterion=self.TypeOfOpeningCriterion, + err_tol_theta=self.ErrTolTheta, + err_tol_force_acc=self.ErrTolForceAcc, + tree_domain_update_frequency=self.TreeDomainUpdateFrequency, + des_num_ngb=self.DesNumNgb, + max_num_ngb_deviation=self.MaxNumNgbDeviation, + unit_length_in_cm=self.UnitLength_in_cm, + unit_mass_in_g=self.UnitMass_in_g, + unit_velocity_in_cm_per_s=self.UnitVelocity_in_cm_per_s, + gravity_constant_internal=self.GravityConstantInternal, + min_gas_hsml_fractional=self.MinGasHsmlFractional, + art_bulk_visc_const=self.ArtBulkViscConst, + include_subfind=includesub, + err_tol_theta_subfind=self.ErrTolThetaSubfind, + des_link_ngb=self.DesLinkNgb)) + + def _checkexistence_button_fired(self): + + figure = self.main.display + figure.clear() + ax = figure.add_subplot(111) + + ax = self.main.display.axes[0] + ax.set_xticks([]) + ax.set_yticks([]) + + + placenormtext(ax,0.03, 0.98,'HALO',10) + placenormtext(ax,0.15, 0.98,'BOXTYPE',10) + placenormtext(ax,0.60, 0.98,'PAD',10) + placenormtext(ax,0.40, 0.98,'LMIN',10) + placenormtext(ax,0.50, 0.98,'LMAX',10) + placenormtext(ax,0.31, 0.98,'NVIR',10) + placenormtext(ax,0.70, 0.98,'ICS',10) + placenormtext(ax,0.77, 0.98,'GADGET',10) + placenormtext(ax,0.91, 0.98,'HALOS',10) + txtdisplace = 0.0 + for boxtypei in self.boxtype: + for nrviri in self.nrvir: + for paddingi in self.padding: + for overlapi in self.overlap: + run = dict(region=boxtypei, padding=paddingi, + levelmin=self.lmin, levelmax=self.lmax, + overlap=overlapi, nrvir=nrviri, zinit=self.zinit) + filepath = naming.run_dir(self.gadpath, self.haloid, **run) + + #print filepath + #print filepath + '/ics.0' + #print filepath + '/outputs/snapdir_064' + #print filepath + '/outputs/groups_064' + try: + with open(filepath + '/ics.0'): + icfound = '+' + except OSError: + icfound = '-' + outputs = os.path.join(filepath, 'outputs') + lastsnap = int(self.nintervals) - 1 + gadfound = '+' if os.path.exists( + naming.snapshot_dir(outputs, lastsnap)) else '-' + halosfound = '+' if os.path.exists( + naming.groups_dir(outputs, lastsnap)) else '-' + + txtdisplace += 0.03 + placenormtext(ax,0.02, 0.98 - txtdisplace,str(self.haloid),10) + placenormtext(ax,0.14, 0.98 - txtdisplace,str(boxtypei.upper()),10) + placenormtext(ax,0.615, 0.98 - txtdisplace,str(paddingi),10) + placenormtext(ax,0.42, 0.98 - txtdisplace,str(self.lmin),10) + placenormtext(ax,0.52, 0.98 - txtdisplace,str(self.lmax),10) + placenormtext(ax,0.325, 0.98 - txtdisplace,str(nrviri),10) + placenormtext(ax,0.71, 0.98 - txtdisplace,str(icfound),10) + placenormtext(ax,0.81, 0.98 - txtdisplace,str(gadfound),10) + placenormtext(ax,0.94, 0.98 - txtdisplace,str(halosfound),10) + + wx.CallAfter(self.main.display.canvas.draw) + + def __init__(self, main, **kwargs): + HasTraits.__init__(self) + self.main = main + self.boxtype = ['ellipsoid'] + self.nrvir = ['3','4','5','6','7','8','9'] + self.padding = ['7','8','9'] + self.overlap = ['4'] + self.lmax = '11' + self.cosmologylist = ['PLANCK'] + self.haloidlist = self.main.candidatestab.haloid + self.gadpath = self.main.headertab.datamasterpath + self.nintervals = '64' + self.redshifti = 46. + self.expfacti = 1./(1.+self.redshifti) + self.InitCondFile = './ics_rewrite' + self.OutputDir = './outputs' + self.EnergyFile = 'energy.txt' + self.InfoFile = 'info.txt' + self.TimingsFile = 'timings.txt' + self.CpuFile = 'cpu.txt' + self.RestartFile = 'restart' + self.SnapshotFileBase = 'snap' + self.OutputListFilename = 'ExpansionList' + self.TimebinFile = 'timebin' + self.ICFormat = 1 + self.SnapFormat = 1 + self.TimeLimitCPU = 340000 # in seconds + self.CpuTimeBetRestartFile = 43200 # in seconds + self.ResubmitOn = 0 + self.ResubmitCommand = 'my-scriptfile' + self.MaxMemSize = 3500 # sets maximum memory use in MByte + self.PartAllocFactor = 2.5 + self.BufferSize = 100 # in MByte + self.TimeBegin = 0.0078125 #%z=127 + self.TimeMax = 1.0 + self.ComovingIntegrationOn = 1 + self.PeriodicBoundariesOn = 1 + self.CoolingOn = 0 + self.StarformationOn = 0 + # Displayed defaults, from the same table the writer uses. HubbleParam is + # the dimensionless h, matching what the parameter file receives. + self.Omega0 = cosmology.DEFAULT.omega_m + self.OmegaLambda = cosmology.DEFAULT.omega_l + self.OmegaBaryon = 0.0 # dark-matter-only runs + self.HubbleParam = cosmology.DEFAULT.h + self.BoxSize = 100.0 + self.OutputListOn = 1 + self.TimeBetSnapshot = 0.0 + self.TimeOfFirstSnapshot = 0.0 + self.TimeBetStatistics = 0.01 + self.NumFilesPerSnapshot = 4 + self.NumFilesWrittenInParallel = 4 + self.TypeOfTimestepCriterion = 0 + self.ErrTolIntAccuracy = 0.012 + self.CourantFac = 0.15 + self.MaxRMSDisplacementFac = 0.125 + self.MaxSizeTimestep = 0.005 + self.MinSizeTimestep = 0.0 + self.InitGasTemp = 1000.0 + self.MinGasTemp = 5.0 + self.TypeOfOpeningCriterion = 1 + self.ErrTolTheta = 0.6 + self.ErrTolForceAcc = 0.0025 + self.TreeDomainUpdateFrequency = 0.01 + self.DesNumNgb = 64 + self.MaxNumNgbDeviation = 1 + self.UnitLength_in_cm = 3.085678e24 # 1.0 Mpc + self.UnitMass_in_g = 1.989e43 # 1.0e10 solar masses + self.UnitVelocity_in_cm_per_s = 1e5 # 1 km/sec + self.GravityConstantInternal = 0 + self.MinGasHsmlFractional = 0.25 + self.SofteningGas = 0.0 + self.SofteningHalo = 0.000610352 + self.SofteningDisk = 0.001220703 + self.SofteningBulge = 0.002441406 + self.SofteningStars = 0.004882813 + self.SofteningBndry = 0.009765625 + self.SofteningGasMaxPhys = 0.0 + self.SofteningHaloMaxPhys = 0.000610352 + self.SofteningDiskMaxPhys = 0.001220703 + self.SofteningBulgeMaxPhys = 0.002441406 + self.SofteningStarsMaxPhys = 0.004882813 + self.SofteningBndryMaxPhys = 0.009765625 + self.ArtBulkViscConst = 1.0 + self.ErrTolThetaSubfind = 0.7 + self.DesLinkNgb = 20 + self.FOF_SECONDARY_LINK_TYPES = '4+8+16+32' + self.ExpansionListArr = np.zeros(shape=(1,4)) + self.clusteropt = self.main.headertab.clusteropt + # Scheduler, queue and any extra directives come from the cluster + # profile in clusters.toml rather than from hardcoded defaults. + profile = self.main.headertab.scheduler_defaults() + self.subtype = profile['scheduler'] + self.username = profile['username'] + if profile['scheduler'] == 'SLURM': + self.SLURMqueue = profile['queue'] + else: + self.PBSqueue = profile['queue'] + self.PBSextralines = profile['extra_lines'] + self.PBSextraflags = profile['extra_flags'] + self._refresh_execute() + + + + diff --git a/halos.py b/src/cme/ui/halos.py similarity index 62% rename from halos.py rename to src/cme/ui/halos.py index c38aa12..f651a70 100644 --- a/halos.py +++ b/src/cme/ui/halos.py @@ -1,19 +1,18 @@ -from Common import * +from cme import naming +from cme.ui.common import * + class HaloFind(HasTraits): halopath = Directory + summarypath = Directory x_scale = Enum(['linear','log']) y_scale = Enum(['linear','log']) - #haloid = Enum([]) - #haloid = List(Int) haloid = Int() - #Enum(['190897','208737','140666','28221','147419','28188','147273','78411','131988','19910']) snapshot = Range(0,255,255) deltar = Range(0.0,10.0,5.0) - id_select = Int(0) haloidlist = List(Int) datastatus = Str halo_varx = Enum(['id','posX','posY','posZ','corevelx','corevely','corevelz', \ @@ -31,7 +30,7 @@ class HaloFind(HasTraits): 'Xoff','Voff','b_to_a','c_to_a','A[x]','A[y]','A[z]','spin_bullock','T/|U|','npart',\ 'num_cp','numstart','desc','flags','n_core','min_pos_err','min_vel_err','min_bulkvel_err' \ 'hostID','offset','particle_offset']) - + quiver_mode = Enum(['2darrow','2dcircle','2dcross','2ddash','2ddiamond','2dhooked_arrow','2dsquare','2dthick_arrow','2dthick_cross','2dtriangle','2dvertex','arrow','axes','cone','cube','cylinder','point','sphere']) enable_haloglyps = Bool(False) plot_button = Button('Plot') @@ -51,7 +50,6 @@ class HaloFind(HasTraits): lmax = Range(7,14,11,auto_set=False) overlap = Range(1,6,4,auto_set=False) nrvir =Range(1,9,3,auto_set=False) - boxtype = Enum(['ellipsoid','box']) boxtype = Enum(['box','ellipsoid']) parentorzoom = Enum(['zoom','parent']) halo_type = Enum(['all halos','hosts only','subhalos only']) @@ -79,7 +77,7 @@ class HaloFind(HasTraits): enabled_when='fullboxopt=="specific halo" or parentorzoom=="zoom"'), Group(HGroup(Item(name='deltar',label='Box Width [Mpc/h]',springy=True),springy=True) ,enabled_when='fullboxopt=="specific halo" or parentorzoom=="zoom"'), - + Group(Item(name='boxtype',label='Which Volume?',style='custom'),enabled_when='parentorzoom=="zoom"'), HGroup(Group(HGroup(Group(Item(name='nrvir',label='n*rvir(z=0)'), Item(name='lmin',label='Level Min'), @@ -127,164 +125,174 @@ def _parentorzoom_changed(self): if self.parentorzoom == 'zoom': self.snapshot = 255 + def _run_kwargs(self): + """The parameter set identifying the zoom resimulation being inspected.""" + return dict(region=self.boxtype, + padding=self.padding, + levelmin=self.lmin, + levelmax=self.lmax, + overlap=self.overlap, + nrvir=self.nrvir) + def gethostid(self): - path = "/bigbang/data/AnnaGroup/caterpillar/halos" - with open(path + "/halosummary.txt") as f: - for line in f: - if line[0] != '#': - linecomp = line.split() - if 'H'+str(self.haloid) == linecomp[0] and 'B'+str(self.boxtype.upper())[0] == linecomp[1] \ - and str(self.lmax) == linecomp[2] and str(self.nrvir) == linecomp[3]: - haloid = linecomp[17] + """The Rockstar ID of this zoom's host halo, or None if not listed. - return haloid + Previously this returned an unbound local when no row matched, raising + UnboundLocalError, even though every caller tests the result for + truthiness as though a miss were expected (AUDIT 2.1.4). + """ + summary = os.path.join(self.summarypath, "halosummary.txt") + if not os.path.exists(summary): + return None - def gethalos_xy(self): + with open(summary) as f: + for line in f: + if line.startswith('#'): + continue + linecomp = line.split() + if len(linecomp) < 18: + continue + if ('H' + str(self.haloid) == linecomp[0] + and 'B' + naming.region_code(self.boxtype) == linecomp[1] + and str(self.lmax) == linecomp[2] + and str(self.nrvir) == linecomp[3]): + return linecomp[17] + + return None + + def select_halo_type(self, halodata): + """The subset of a catalogue matching the selected halo type.""" + if self.halo_type == 'hosts only': + return halodata.get_hosts() + if self.halo_type == 'subhalos only': + return halodata.get_subs() + return halodata.data + + def catalogue_path(self): + """Path to the Rockstar catalogue for whichever mode is selected.""" if self.parentorzoom == 'parent': - self.halopath = self.main.headertab.parentsimpath + 'RockstarData' - if os.path.exists(self.halopath): - halodata = RSDataReader.RSDataReader(self.halopath,self.snapshot,digits=2) - self.datastatus = 'Parent found.' - dataexists = True - else: - dataexists = False - self.datastatus = 'Parent not found.' - - elif self.parentorzoom == 'zoom': - self.foldername = 'H' + str(self.haloid) + \ - '_B' + str(self.boxtype.upper())[0] + \ - '_Z127' + \ - '_P' + str(self.padding) + \ - '_LN' + str(self.lmin) + \ - '_LX' + str(self.lmax) + \ - '_O' + str(self.overlap) + \ - '_NV' + str(self.nrvir) - - self.halopath = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/rockstardata" - - if os.path.exists(self.halopath): - halodata = RSDataReader.RSDataReader(self.halopath,self.snapshot,digits=2) - self.datastatus = 'Zoom found.' - dataexists = True - else: - dataexists = False - self.datastatus = 'Zoom not found.' - - if dataexists: - if self.halo_type == 'all halos': - tmphalos = halodata.data - if self.halo_type == 'hosts only': - tmphalos = halodata.get_hosts() - if self.halo_type == 'subhalos only': - tmphalos = halodata.get_subs() - if self.halo_type == 'subs of ID': - tmphalos = halodata.get_subhalos_from_halo(self.haloid) + return os.path.join(self.main.headertab.parentsimpath, 'RockstarData') + self.foldername = naming.run_dir_name(halo_id=self.haloid, **self._run_kwargs()) + return naming.rockstar_dir(self.gadpath, self.haloid, **self._run_kwargs()) + + def load_catalogue(self): + """Read the selected catalogue, or return None if it is not on disk.""" + self.halopath = self.catalogue_path() + label = 'Parent' if self.parentorzoom == 'parent' else 'Zoom' + if not os.path.exists(self.halopath): + self.datastatus = f'{label} not found.' + return None + self.datastatus = f'{label} found.' + return RSDataReader.RSDataReader(self.halopath, self.snapshot, digits=2) + + def gethalos_xy(self): + """The selected x and y quantities, restricted to the region of interest. + + Returns empty arrays when the catalogue is missing. Previously the + function fell through to `return x, y` with both names unbound + (AUDIT 2.1.5). + """ + halodata = self.load_catalogue() + if halodata is None: + return np.array([]), np.array([]) + + tmphalos = self.select_halo_type(halodata) + + # Only the zoom branch establishes a host. The radius block below used + # to read `idhost` unconditionally, so every parent full-box plot with + # two position axes raised NameError (AUDIT 2.1.5). + idhost = None + condh = None - - if self.fullboxopt == 'full box' and self.parentorzoom == 'parent': - x = np.array(tmphalos[self.halo_varx]) - y = np.array(tmphalos[self.halo_vary]) - elif self.parentorzoom == 'zoom': - idhost = self.gethostid() - if idhost: - hosts = halodata.get_hosts() - subs = halodata.get_subs() - host = hosts.ix[int(idhost)] - subsinhost = halodata.get_subhalos_from_halo(self.haloid) - xposhost = float(host['posX']) - yposhost = float(host['posY']) - zposhost = float(host['posZ']) - self.zoomid = int(idhost) - self.hostmvir = host['mvir']/halodata.h0 - self.hostrvir = host['rvir'] - self.hostrvmax = host['rvmax'] - self.hostposx = host['posZ'] - self.hostposy = host['posY'] - self.hostposz = host['posZ'] - self.nhosts = len(hosts['posX']) - self.nsubs = len(subs['posX']) - self.sinhost = len(subsinhost['posX']) - cond1h = (np.array(tmphalos['posX']) >= xposhost - self.deltar/2.) & (np.array(tmphalos['posX']) <= xposhost + self.deltar/2.) - cond2h = (np.array(tmphalos['posY']) >= yposhost - self.deltar/2.) & (np.array(tmphalos['posY']) <= yposhost + self.deltar/2.) - cond3h = (np.array(tmphalos['posZ']) >= zposhost - self.deltar/2.) & (np.array(tmphalos['posZ']) <= zposhost + self.deltar/2.) - condh = cond1h & cond2h & cond3h - - x = np.array(tmphalos[self.halo_varx][condh]) - y = np.array(tmphalos[self.halo_vary][condh]) - self.datastatus = 'Host found.' - elif not idhost: - self.datastatus = "No host found, plotting all." - x = np.array(tmphalos[self.halo_varx]) - y = np.array(tmphalos[self.halo_vary]) - - if 'pos' in self.halo_varx and 'pos' in self.halo_vary: - if idhost: - rvir = np.array(tmphalos['rvir'][condh]) - elif not idhost: - rvir = np.array(tmphalos['rvir']) - - x,y = drawcircle(x,y,rvir/1000) - + if self.parentorzoom == 'zoom': + idhost = self.gethostid() + + if idhost: + hosts = halodata.get_hosts() + subs = halodata.get_subs() + host = hosts.loc[int(idhost)] + subsinhost = halodata.get_subhalos_from_halo(self.haloid) + xposhost = float(host['posX']) + yposhost = float(host['posY']) + zposhost = float(host['posZ']) + self.zoomid = int(idhost) + self.hostmvir = host['mvir']/halodata.h0 + self.hostrvir = host['rvir'] + self.hostrvmax = host['rvmax'] + self.hostposx = host['posX'] + self.hostposy = host['posY'] + self.hostposz = host['posZ'] + self.nhosts = len(hosts['posX']) + self.nsubs = len(subs['posX']) + self.sinhost = len(subsinhost['posX']) + + half = self.deltar/2. + condh = np.ones(len(tmphalos['posX']), dtype=bool) + for axis, centre in (('posX', xposhost), + ('posY', yposhost), + ('posZ', zposhost)): + values = np.array(tmphalos[axis]) + condh &= (values >= centre - half) & (values <= centre + half) + + x = np.array(tmphalos[self.halo_varx][condh]) + y = np.array(tmphalos[self.halo_vary][condh]) + self.datastatus = 'Host found.' else: - self.datastatus = 'Data not found or plotted.' + if self.parentorzoom == 'zoom': + self.datastatus = "No host found, plotting all." + x = np.array(tmphalos[self.halo_varx]) + y = np.array(tmphalos[self.halo_vary]) + + if 'pos' in self.halo_varx and 'pos' in self.halo_vary: + rvir = np.array(tmphalos['rvir'] if condh is None + else tmphalos['rvir'][condh]) + x, y = drawcircle(x, y, rvir/1000) + + return x, y - return x,y - def _plot_button_fired(self): - if self.parentorzoom == 'parent': - self.halopath = self.main.headertab.parentsimpath + 'RockstarData' - if os.path.exists(self.halopath): - dataexists = True - else: - dataexists = False - - elif self.parentorzoom == 'zoom': - self.foldername = 'H' + str(self.haloid) + \ - '_B' + str(self.boxtype.upper())[0] + \ - '_Z127' + \ - '_P' + str(self.padding) + \ - '_LN' + str(self.lmin) + \ - '_LX' + str(self.lmax) + \ - '_O' + str(self.overlap) + \ - '_NV' + str(self.nrvir) - - self.halopath = self.gadpath + 'halos/H' + str(self.haloid) + '/' + self.foldername + "/rockstardata" - - if os.path.exists(self.halopath): - dataexists = True - else: - dataexists = False - - - if hasattr(self, 'display_points'): - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - - if dataexists: - ax = self.main.display.axes[0] - x,y = self.gethalos_xy() - xmin_use = x.min() - xmax_use = x.max() - ymin_use = y.min() - ymax_use = y.max() - - if 'pos' in self.halo_varx and 'pos' in self.halo_vary and self.fullboxopt == 'specific halo': - self.display_points = ax.plot(x,y,linestyle='-',linewidth=self.main.markersize,color=self.main.markercolor) - else: - self.display_points = ax.plot(x,y,marker=self.main.markerstyle,linestyle='none',markersize=self.main.markersize,color=self.main.markercolor,markeredgecolor=self.main.markercolor) - - ax.set_xlim(xmin_use,xmax_use) - ax.set_ylim(ymin_use,ymax_use) - ax.set_xlabel(self.halo_varx) - ax.set_ylabel(self.halo_vary) - ax.set_xscale(self.x_scale) - ax.set_yscale(self.y_scale) - #self.datastatus = 'X-Y plotted.' - wx.CallAfter(self.main.display.canvas.draw) + # The plotting block below used to sit inside the `elif ... == 'zoom'` + # branch, so pressing Plot in parent mode computed a path and then fell + # off the end of the method without drawing anything (AUDIT 2.1.6). + self.halopath = self.catalogue_path() + if not os.path.exists(self.halopath): + self.datastatus = 'Data not found or plotted.' + return + + if hasattr(self, 'display_points'): + figure = self.main.display + figure.clear() + figure.add_subplot(111) + + x, y = self.gethalos_xy() + if len(x) == 0: + return + + ax = self.main.display.axes[0] + if ('pos' in self.halo_varx and 'pos' in self.halo_vary + and self.fullboxopt == 'specific halo'): + self.display_points = ax.plot(x, y, + linestyle='-', + linewidth=self.main.markersize, + color=self.main.markercolor) + else: + self.display_points = ax.plot(x, y, + marker=self.main.markerstyle, + linestyle='none', + markersize=self.main.markersize, + color=self.main.markercolor, + markeredgecolor=self.main.markercolor) + + ax.set_xlim(x.min(), x.max()) + ax.set_ylim(y.min(), y.max()) + ax.set_xlabel(self.halo_varx) + ax.set_ylabel(self.halo_vary) + ax.set_xscale(self.x_scale) + ax.set_yscale(self.y_scale) + wx.CallAfter(self.main.display.canvas.draw) + - def _quiver_button_fired(self): self.main.scene.mlab.clf(figure=self.main.scene.mayavi_scene) @@ -299,7 +307,7 @@ def _quiver_button_fired(self): tmphalos = halodata.get_subs() if self.halo_type == 'subs of ID': tmphalos = halodata.get_subhalos_from_halo(self.haloid) - + x = np.array(tmphalos['posX']) y = np.array(tmphalos['posY']) z = np.array(tmphalos['posZ']) @@ -310,7 +318,7 @@ def _quiver_button_fired(self): else: allhalos = halodata.get_hosts() - halotmp = allhalos.ix[int(self.haloid)] + halotmp = allhalos.loc[int(self.haloid)] xposhost = float(halotmp['posX']) yposhost = float(halotmp['posY']) @@ -320,7 +328,7 @@ def _quiver_button_fired(self): cond2h = (np.array(halodata.data['posY']) >= yposhost - self.deltar/2.) & (np.array(halodata.data['posY']) <= yposhost + self.deltar/2.) cond3h = (np.array(halodata.data['posZ']) >= zposhost - self.deltar/2.) & (np.array(halodata.data['posZ']) <= zposhost + self.deltar/2.) - condh = cond1h & cond2h & cond3h + condh = cond1h & cond2h & cond3h x = np.array(halodata.data['posX'][condh]) y = np.array(halodata.data['posY'][condh]) @@ -330,11 +338,11 @@ def _quiver_button_fired(self): vz = np.array(halodata.data['pecVZ'][condh]) rvir = np.array(halodata.data['rvir'][condh]) - + vmag = np.sqrt(vx**2+vy**2+vz**2) self.main.scene.mlab.quiver3d(x,y,z,vx,vy,vz,line_width=self.quiver_width,opacity=self.opacity_select,mode=self.quiver_mode,scalars=vmag) - + if self.enable_haloglyps and self.fullboxopt == 'specific halo': self.main.scene.mlab.points3d(x, y, z, rvir/1000) @@ -352,7 +360,6 @@ def _quiver_button_fired(self): def _plotxyzrvir_button_changed(self): self.main.scene.mlab.clf(figure=self.main.scene.mayavi_scene) halodata = RSDataReader.RSDataReader(self.halopath,self.snapshot,digits=2) - boxwidth = self.deltar/2 if self.fullboxopt == 'full box': if self.halo_type == 'all halos': @@ -363,16 +370,16 @@ def _plotxyzrvir_button_changed(self): tmphalos = halodata.get_subs() if self.halo_type == 'subs of ID': tmphalos = halodata.get_subhalos_from_halo(self.haloid) - + x = tmphalos['posX'] y = tmphalos['posY'] - z = tmphalos['posY'] + z = tmphalos['posZ'] rvir = tmphalos['rvir'] self.main.scene.mlab.points3d(x, y, z, rvir/1000,colormap="copper") - + else: allhalos = halodata.get_hosts() - halotmp = allhalos.ix[int(self.haloid)] + halotmp = allhalos.loc[int(self.haloid)] xposhost = float(halotmp['posX']) yposhost = float(halotmp['posY']) @@ -384,40 +391,30 @@ def _plotxyzrvir_button_changed(self): condh = cond1h & cond2h & cond3h - condid = (np.array(halodata.data['id']) == self.haloid) - - condnew = condh & condid - x = np.array(halodata.data['posX'][condh]) y = np.array(halodata.data['posY'][condh]) z = np.array(halodata.data['posZ'][condh]) rvir = np.array(halodata.data['rvir'][condh]) - rvirn = np.array(halodata.data['rvir'][condnew]) - mvir = np.array(halodata.data['mvir'][condnew]) - rvmax = np.array(halodata.data['rvmax'][condnew]) - vmax = np.array(halodata.data['vmax'][condnew]) - print mvir,mvir/0.6711,rvirn,rvmax,vmax,xposhost,yposhost,zposhost - extent = [xposhost-boxwidth,xposhost+boxwidth,yposhost-boxwidth,yposhost+boxwidth,zposhost-boxwidth,zposhost+boxwidth] self.main.scene.mlab.points3d(x, y, z, rvir/1000,colormap="copper") self.main.scene.mlab.colorbar(orientation='vertical',title='rvir') #self.main.scene.mlab.outline(extent = extent) - #self.main.scene.mlab.axes(extent = extent) - + #self.main.scene.mlab.axes(extent = extent) + self.main.scene.mlab.orientation_axes(xlabel='x-pos',ylabel='x-pos',zlabel='z-pos') self.main.scene.mlab.show() self.main.scene.mlab.axes() self.main.scene.mlab.outline() def _x_scale_changed(self): - if hasattr(self, 'display_points'): + if hasattr(self, 'display_points'): # self.display_points[0].remove() self.main.display.axes[0].set_xscale(self.x_scale) wx.CallAfter(self.main.display.canvas.draw) def _y_scale_changed(self): - if hasattr(self, 'display_points'): + if hasattr(self, 'display_points'): #self.display_points[0].remove() self.main.display.axes[0].set_yscale(self.y_scale) wx.CallAfter(self.main.display.canvas.draw) @@ -438,4 +435,6 @@ def __init__(self, main, **kwargs): self.halo_varx = 'posX' self.halo_vary = 'posY' self.gadpath = self.main.headertab.datamasterpath - self.halopath = self.main.headertab.parentsimpath + 'RockstarData' \ No newline at end of file + # Replaces a hardcoded /bigbang absolute path in gethostid(). + self.summarypath = os.path.join(self.gadpath, 'halos') + self.halopath = self.main.headertab.parentsimpath + 'RockstarData' diff --git a/src/cme/ui/home.py b/src/cme/ui/home.py new file mode 100644 index 0000000..55d2a43 --- /dev/null +++ b/src/cme/ui/home.py @@ -0,0 +1,78 @@ +"""Home tab: pick a cluster profile and confirm the derived paths.""" + +from cme import config +from cme.ui.common import * + + +class Header(HasTraits): + + view = View(Item(name='clusteropt', label='Cluster', padding=5), + Item(name='configfile', label='Profiles', style='readonly', padding=5), + Item(name='currentdir', label='CWD', style='readonly', padding=5), + Item(name='username', label='Username', padding=5), + Item(name='masterpath', label='Home', padding=5), + Item(name='gadgetpath', label='Gadget', padding=5), + Item(name='musicpath', label='Music', padding=5), + Item(name='parentsimpath', label='Parent Sim.', padding=5), + Item(name='datamasterpath', label='Project Data', padding=5)) + + clusteropt = Enum(values='clusternames') + clusternames = List(Str) + configfile = Str + username = Str + masterpath = Directory + homepath = Directory + gadgetpath = Directory + musicpath = Directory + datamasterpath = Directory + parentsimpath = Directory + currentdir = Directory + + def _apply_profile(self): + """Recompute every derived path from the selected profile and username. + + This replaces an if-chain on platform.node() that hardcoded absolute + paths and two usernames, plus a _username_changed handler that derived a + different and inconsistent set of paths from the same inputs. + """ + paths = config.resolve(self.clusteropt, + config=self._config, + username=self.username or None) + self.homepath = paths['home'] + self.masterpath = paths['master'] + self.gadgetpath = paths['gadget'] + self.musicpath = paths['music'] + self.datamasterpath = paths['data'] + self.parentsimpath = paths['parent'] + + def _username_changed(self): + self._apply_profile() + + def _clusteropt_changed(self): + self._apply_profile() + + def scheduler_defaults(self): + """Scheduler, queue and extra directives for the selected profile. + + The Gadget tab reads these instead of carrying hardcoded queue names. + """ + return config.resolve(self.clusteropt, + config=self._config, + username=self.username or None) + + def __init__(self, main, **kwargs): + HasTraits.__init__(self) + self.main = main + + self._config = config.load() + self.clusternames = config.profile_names(self._config) + self.configfile = config.config_path() or '(built-in default)' + self.currentdir = os.getcwd() + + detected = config.detect(self._config) + paths = config.resolve(detected, config=self._config) + self.username = paths['username'] + # Assigning clusteropt fires _clusteropt_changed, which applies the + # profile; set it last so the username is already in place. + self.clusteropt = detected + self._apply_profile() diff --git a/ics.py b/src/cme/ui/ics.py similarity index 58% rename from ics.py rename to src/cme/ui/ics.py index 64cbeca..183d0cd 100644 --- a/ics.py +++ b/src/cme/ui/ics.py @@ -1,8 +1,20 @@ -from Common import * -from glob import glob +# `reWriteIC` post-processes MUSIC's output into the block layout Gadget wants. +# It was previously aliased to `re`, shadowing the standard library module. +import modules.convertfiles.reWriteIC as rewriteic + +from cme import cosmology, naming, shell, writers +from cme.ui.common import * +from cme.writers import music + +#: Side length of the Caterpillar parent box, in Mpc/h. +PARENT_BOXLENGTH = 100 + +#: Paths to the parent simulation's final snapshot and its initial conditions, +#: relative to the parent simulation directory. Hardcoded to the Caterpillar +#: 512^3 parent; these belong in configuration. +PARENT_SNAPSHOT = "/512Parent/outputs/snapdir_063/snap_063" +PARENT_ICS = "/512Parent/ics/ics" -import random -import modules.convertfiles.reWriteIC as re class InitialConditions(HasTraits): @@ -27,9 +39,9 @@ class InitialConditions(HasTraits): cosmologylist = List( editor = CheckListEditor(values = ['WMAP1','WMAP3','WMAP5','WMAP7','WMAP9','PLANCK'],cols = 6) ) boxlength = List(editor = CheckListEditor(values = ['5','10','25','50','75','100','250','500'],cols= 4)) - + zinit = Range(0,127,127) - + padding = List(editor = CheckListEditor(values = ['5','6','7','8','9','10'],cols=6) ) lmin = Enum(['7','8','9','10','11','12','13','14','15']) #lmin = List(editor = CheckListEditor(values = ['7','8','9','10','11','12','13','14','15'],cols=9)) @@ -71,8 +83,8 @@ class InitialConditions(HasTraits): #POISSON fftfine = Bool(False) accuracy = Float - presmooth = 3 - postsmooth = 3 + presmooth = Int(3) + postsmooth = Int(3) smoother = Str laplaceorder = Int gradorder = Int @@ -176,7 +188,7 @@ class InitialConditions(HasTraits): Group(Item(name='lmax',label='Level Max',style='custom'), Item(name='padding',label='Padding',style='custom',springy=False), - Item(name='overlap',label='Overlap',style='custom'),enabled_when='resimbox==True'),show_border=True,label='Iterative Components') + Item(name='overlap',label='Overlap',style='custom'),enabled_when='resimbox==True'),show_border=True,label='Iterative Components') ,label='Setup'), Group(Item(name='fftfine' ,label='fft_fine'), @@ -186,7 +198,7 @@ class InitialConditions(HasTraits): Item(name='smoother',label='smoother',width=-60), Item(name='laplaceorder' ,label='laplace_order',width=-60), Item(name='gradorder' ,label='grad_order',width=-60), - + HGroup(Item(name='baryons' ,label='Baryons'), Item(name='use2LPT' ,label='use_2LPT'), Item(name='useLLA' ,label='use_LLA'), @@ -203,130 +215,179 @@ class InitialConditions(HasTraits): Item(name='confstatus',label='Status',style='readonly'), Group(Item(name='executemusic',label='Execute?'),Item(name='generate_button',show_label=False),enabled_when='haloidselect in haloid'),label='Output'))) + #: Scratch files MUSIC leaves in the output directory. + MUSIC_SCRATCH = ("wnoise*", "temp*") + + def run_music(self, confname, workdir, cleanup=False): + """Invoke MUSIC on a configuration file, optionally tidying up after. + + Previously this joined `cd `, the MUSIC command and `rm wnoise* + temp*` with semicolons and ran them through a shell. Both paths come + from Directory traits, so a space split the command and a metacharacter + executed; and because the parts were `;`-joined rather than `&&`-joined, + the `rm` ran even when MUSIC had failed. + """ + binary = os.path.join(str(self.musicpath), "MUSIC") + self.confstatus = f"Running MUSIC on {os.path.basename(confname)}..." + result = shell.run([binary, confname], cwd=workdir) + if result.returncode != 0: + self.confstatus = f"MUSIC failed ({result.returncode})." + return result + if cleanup: + shell.remove_matching(workdir, self.MUSIC_SCRATCH) + self.confstatus = "Generated initial conditions." + return result + def _parentbox_changed(self): - if self.parentbox == True: + if self.parentbox: self.resimbox = False def _resimbox_changed(self): - if self.resimbox == True: + if self.resimbox: self.parentbox = False def _generate_button_fired(self): if self.haloidselect in self.haloid: - if self.parentbox == True: + if self.parentbox: for cosmi in self.cosmologylist: for boxlengthi in self.boxlength: foldername = cosmi + \ '_L' + str(boxlengthi) + \ '_Z' + str(self.zinit) + \ '_LMIN' + str(self.boxlevel) - + #filepath = self.outpath + 'halos/H' + str(self.haloid) + '/' + foldername - + if not os.path.exists(self.outpath + 'parent/'): os.makedirs(self.outpath + 'parent/') - + if not os.path.exists(self.outpath + 'parent/' + foldername): os.makedirs(self.outpath + 'parent/' + foldername) - + writepath = self.outpath + 'parent/' + foldername - confname = self.outpath + 'parent/' + foldername + '/' + foldername + '.conf' - - - omegam,omegal,omegab,hubble,sigma8,nspec = cosmoconstant(cosmi) - - baryonsstr = determineboolstr(self.baryons) - use2LPTstr = determineboolstr(self.use2LPT) - useLLAstr = determineboolstr(self.useLLA) - periodicTFstr = determineboolstr(self.periodicTF) - fftfinestr = determineboolstr(self.fftfine) - alignstr = determineboolstr(self.align) - padding = 8 - overlap = 4 - - constructparentconf(confname,boxlengthi,self.zinit,self.lTF,padding,overlap,self.refx,self.refy,self.refz, \ - self.extentx,self.extenty,self.extentz,alignstr,baryonsstr,use2LPTstr,useLLAstr,omegam,omegal,omegab,hubble, \ - sigma8,nspec,self.tranfunc[0],self.seedinit,self.outformat,'./' + self.outfilename,fftfinestr,self.accuracy,self.presmooth,self.postsmooth, \ - self.smoother,self.laplaceorder,self.gradorder,self.boxlevel,periodicTFstr,self.noutput) - + confname = os.path.join(writepath, foldername + '.conf') + + writers.write(confname, music.parent_conf( + cosmo=cosmology.get(cosmi), + boxlength=boxlengthi, + zstart=self.zinit, + level=self.boxlevel, + levelmin_TF=self.lTF, + padding=8, + overlap=4, + ref_center=(self.refx, self.refy, self.refz), + ref_extent=(self.extentx, self.extenty, self.extentz), + seed=self.seedinit, + align_top=self.align, + baryons=self.baryons, + use_2LPT=self.use2LPT, + use_2LLA=self.useLLA, + periodic_TF=self.periodicTF, + transfer=self.tranfunc[0], + out_format=self.outformat, + ic_filename='./' + self.outfilename, + num_files=self.noutput, + fft_fine=self.fftfine, + accuracy=self.accuracy, + pre_smooth=self.presmooth, + post_smooth=self.postsmooth, + smoother=self.smoother, + laplace_order=self.laplaceorder, + grad_order=self.gradorder, + )) + self.confstatus = 'Generated halo configuration files.' - runmusic = self.musicpath + '/MUSIC ' + confname - cding = "cd " + writepath - print "EXECUTING..." - print runmusic - subprocess.call(';'.join([cding, runmusic]), shell=True) - re.getBlocks(writepath) - - elif self.parentbox == False: + self.run_music(confname, writepath) + rewriteic.getBlocks(writepath) + + elif not self.parentbox: for cosmi in self.cosmologylist: for boxtypei in self.boxtype: for nrviri in self.nrvir: for paddingi in self.padding: - for lmini in self.lmin: - for lmaxi in self.lmax: + # lmin is an Enum holding a single value, not a + # CheckListEditor list like the others. Iterating + # it walked the characters of the string, so a + # levelmin of 10 or above ran twice with the + # wrong values ('1' then '0'). + lmini = self.lmin + for lmaxi in self.lmax: for overlapi in self.overlap: - foldername = 'H' + str(self.haloidselect) + \ - '_B' + str(boxtypei.upper())[0] + \ - '_Z' + str(self.zinit) + \ - '_P' + str(paddingi) + \ - '_LN' + str(lmini) + \ - '_LX' + str(lmaxi) + \ - '_O' + str(overlapi) + \ - '_NV' + str(nrviri) - - #dirtest = "/n/home01/bgriffen/data/caterpillar/" - filepath = self.outpath + 'halos/H' + str(self.haloidselect) + '/' + foldername - + run = dict(region=boxtypei, padding=paddingi, + levelmin=lmini, levelmax=lmaxi, + overlap=overlapi, nrvir=nrviri, + zinit=self.zinit) + foldername = naming.run_dir_name(halo_id=self.haloidselect, **run) + filepath = naming.run_dir(self.outpath, self.haloidselect, **run) + if os.path.exists(filepath): self.confstatus = 'DIR EXIST!' - + elif not os.path.exists(filepath): os.makedirs(filepath) - + #pointfile = dirtest + 'ics/lagr/H' + str(self.haloidselect) + 'NRVIR' + str(int(nrviri)) #pointfile2 = dirtest + 'ics/lagr/H' + str(self.haloidselect) + 'NRVIR' + str(int(nrviri)) - - pointfile = self.outpath + 'ics/lagr/H' + str(self.haloidselect) + 'NRVIR' + str(int(nrviri)) - + + pointfile = naming.lagr_region_path( + os.path.join(self.outpath, 'ics', 'lagr'), + self.haloidselect, nrviri) + #if lmaxi < 13: # pointfile2 = pointfile - writepath = filepath + writepath = filepath #self.outpath + 'halos/' + foldername - confname = filepath + '/' + foldername + '.conf' + confname = os.path.join(filepath, foldername + '.conf') #self.outpath + 'halos/' + foldername + '/' + foldername + '.conf' - + self.centx,self.centy,self.centz,self.extx,self.exty,self.extz = getcentext(pointfile + '.head') #print self.parentbox #print self.centx,self.centy,self.centz #print self.extx,self.exty,self.extz - omegam,omegal,omegab,hubble,sigma8,nspec = cosmoconstant(cosmi) - - baryonsstr = determineboolstr(self.baryons) - use2LPTstr = determineboolstr(self.use2LPT) - useLLAstr = determineboolstr(self.useLLA) - periodicTFstr = determineboolstr(self.periodicTF) - fftfinestr = determineboolstr(self.fftfine) - alignstr = determineboolstr(self.align) - boxlength = 100 - #print self.haloidselect - constructresimconf(confname,boxlength,self.zinit,lmini,self.lTF,lmaxi,paddingi,overlapi,self.centx,self.centy,self.centz, \ - self.extx,self.exty,self.extz,alignstr,baryonsstr,use2LPTstr,useLLAstr,omegam,omegal,omegab,hubble, \ - sigma8,nspec,self.tranfunc[0],self.parentseednum,self.parentseedlevel,self.outformat,'./' + self.outfilename,fftfinestr,self.accuracy,self.presmooth,self.postsmooth, \ - self.smoother,self.laplaceorder,self.gradorder,self.boxlevel,periodicTFstr,pointfile,boxtypei,self.noutput,self.haloidselect,nrviri) - + writers.write(confname, music.resim_conf( + cosmo=cosmology.get(cosmi), + boxlength=PARENT_BOXLENGTH, + zstart=self.zinit, + levelmin=lmini, + levelmin_TF=self.lTF, + levelmax=lmaxi, + padding=paddingi, + overlap=overlapi, + region=boxtypei, + region_point_file=pointfile, + ref_center=(self.centx, self.centy, self.centz), + ref_extent=(self.extx, self.exty, self.extz), + seed_level=self.parentseedlevel, + base_seed=self.parentseednum, + halo_id=self.haloidselect, + nrvir=nrviri, + align_top=self.align, + baryons=self.baryons, + use_2LPT=self.use2LPT, + use_2LLA=self.useLLA, + periodic_TF=self.periodicTF, + transfer=self.tranfunc[0], + out_format=self.outformat, + ic_filename='./' + self.outfilename, + num_files=self.noutput, + fft_fine=self.fftfine, + accuracy=self.accuracy, + pre_smooth=self.presmooth, + post_smooth=self.postsmooth, + smoother=self.smoother, + laplace_order=self.laplaceorder, + grad_order=self.gradorder, + )) + self.confstatus = 'Generated halo configuration files.' - if self.executemusic == True: - runmusic = self.musicpath + '/MUSIC ' + confname - cding = "cd " + writepath - rmfiles = "rm wnoise* temp*" - print "EXECUTING..." - print runmusic - subprocess.call(';'.join([cding,runmusic,rmfiles]), shell=True) + if self.executemusic: + self.run_music(confname, writepath, + cleanup=True) #cpconvert = "cp ./lib/reWriteIC.py ./lib/convertics.py " + writepath #runconvert = "python convertics.py" #rmconvert = "rm reWriteIC.py convertics.py" - re.getBlocks(writepath) + rewriteic.getBlocks(writepath) else: self.confstatus = "Please select only IDs from list." @@ -336,30 +397,47 @@ def _masterpath_changed(self): #self.main.existencetab.masterpath = self.masterpath #self.lagroutputdir = self.main.headertab.masterpath + '/' + self.toplagr - def _haloidselect_changed(self): - self.lagroutputname = 'HALO' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) - self.resimlagrfile = 'halos' + str(self.toplagr) + '/H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) + def _selected_nrvir(self): + """The single chosen nrvir value, or None if not exactly one is selected. + + The trait is a CheckListEditor list, so formatting it with str() yields + "NRVIR['1']" -- which is what the displayed pointer paths used to show + while the files were written with int(self.nrvir[0]) (AUDIT 2.2.4). + """ + return self.nrvir[0] if len(self.nrvir) == 1 else None + + def _refresh_lagr_labels(self): + """Recompute the displayed Lagrangian pointer name and path.""" + nrvir = self._selected_nrvir() + if nrvir is None: + self.lagroutputname = '' + self.resimlagrfile = '' + return + self.lagroutputname = os.path.basename( + naming.lagr_region_path('', self.haloidselect, nrvir)) + self.resimlagrfile = naming.lagr_region_path( + str(self.toplagr), self.haloidselect, nrvir) - self._existencebutton_fired() + def _haloidselect_changed(self): + self._refresh_lagr_labels() + self._existencebutton_fired() def _nrvir_changed(self): - self.lagroutputname = 'HALO' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) - self.resimlagrfile = str(self.toplagr) + '/H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) - self._existencebutton_fired() + self._refresh_lagr_labels() + self._existencebutton_fired() def _toplagr_changed(self): - self.lagroutputdir = str(self.main.headertab.datamasterpath) + str(self.toplagr) - self.lagroutputname = 'H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) - self.resimlagrfile = str(self.toplagr) + '/H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) - self._existencebutton_fired() + self.lagroutputdir = os.path.join( + str(self.main.headertab.datamasterpath), str(self.toplagr)) + self._refresh_lagr_labels() + self._existencebutton_fired() def _resimlagrdir_changed(self): - self.resimlagrdir = str(self.main.headertab.datamasterpath) - self.resimlagrfile = str(self.toplagr) + '/H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) + self.resimlagrdir = str(self.main.headertab.datamasterpath) + self._refresh_lagr_labels() def _resimlagrfile_changed(self): - self.resimlagrdir = str(self.main.headertab.datamasterpath) - self.resimlagrfile = str(self.toplagr) + '/H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) + self.resimlagrdir = str(self.main.headertab.datamasterpath) #def _parentsimpath_changed(self): #if len(os.path.basename(glob(self.parentsimpath + "*.conf")[0])) == 1: @@ -370,11 +448,11 @@ def _resimlagrfile_changed(self): def _existencebutton_fired(self): if len(self.nrvir) == 1: - filename = self.lagroutputdir + '/H' + str(self.haloidselect) + 'NRVIR' + str(int(self.nrvir[0])) + filename = naming.lagr_region_path(self.lagroutputdir, self.haloidselect, self.nrvir[0]) try: with open(filename): self.filestatus = 'Lagrangian file exists.' - except IOError: + except OSError: self.filestatus = 'Does not exist.' elif len(self.nrvir) == 0: @@ -397,7 +475,7 @@ def _projopt_changed(self): ytmp = self.ypos xlabel = 'x-pos' ylabel = 'y-pos' - + if self.projopt == 'xz': xbox = self.centx - 0.5*self.extx ybox = self.centz - 0.5*self.extz @@ -407,7 +485,7 @@ def _projopt_changed(self): ytmp = self.zpos xlabel = 'x-pos' ylabel = 'z-pos' - + if self.projopt == 'yz': xbox = self.centy - 0.5*self.exty ybox = self.centz - 0.5*self.extz @@ -417,7 +495,7 @@ def _projopt_changed(self): ytmp = self.zpos xlabel = 'y-pos' ylabel = 'z-pos' - + ax = self.main.display.axes[0] ax.add_patch(patches.Rectangle((xbox,ybox),xext,yext,facecolor='none', edgecolor=self.main.markercolor, @@ -446,19 +524,19 @@ def _projopt_changed(self): def _makeic_button_fired(self): for Nrvir in self.nrvir: - filename = self.lagroutputdir + '/H' + str(self.haloidselect) + 'NRVIR' + str(int(Nrvir)) + filename = naming.lagr_region_path(self.lagroutputdir, self.haloidselect, Nrvir) try: with open(filename): if len(self.nrvir) == 1: - print "Visualising:", 'HALO' + str(self.haloidselect) + 'NRVIR' + str(int(self.nrvir[0])) + print("Visualising:", 'HALO' + str(self.haloidselect) + 'NRVIR' + str(int(self.nrvir[0]))) figure = self.main.display figure.clear() ax = figure.add_subplot(111) - + self.xpos,self.ypos,self.zpos = getlagrxyz(filename) self.centx,self.centy,self.centz,self.extx,self.exty,self.extz = getcentext(filename + '.head') - + if self.projopt == 'xy': xbox = self.centx - 0.5*self.extx ybox = self.centy - 0.5*self.exty @@ -468,7 +546,7 @@ def _makeic_button_fired(self): ytmp = self.ypos xlabel = 'x-pos' ylabel = 'y-pos' - + if self.projopt == 'xz': xbox = self.centx - 0.5*self.extx ybox = self.centz - 0.5*self.extz @@ -478,7 +556,7 @@ def _makeic_button_fired(self): ytmp = self.zpos xlabel = 'x-pos' ylabel = 'z-pos' - + if self.projopt == 'yz': xbox = self.centy - 0.5*self.exty ybox = self.centz - 0.5*self.extz @@ -488,7 +566,7 @@ def _makeic_button_fired(self): ytmp = self.zpos xlabel = 'y-pos' ylabel = 'z-pos' - + ax = self.main.display.axes[0] self.lagrnpart = len(xtmp) self.lagrvol = self.extx*self.exty*self.extz @@ -505,21 +583,21 @@ def _makeic_button_fired(self): ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) wx.CallAfter(self.main.display.canvas.draw) - + else: self.filestatus = 'Not plotting more than one region, select only one nrvir value!' figure = self.main.display figure.clf() ax = figure.add_subplot(111) - except IOError: - try: - with open(self.candidatefiledir + self.candidatefilename): pass - except IOError: - print 'CANT FIND CANDIDATE LIST - CHECK DIRECTORIES!' + except OSError: + candidatefile = os.path.join(str(self.candidatefiledir), + str(self.candidatefilename)) + if not os.path.exists(candidatefile): + self.filestatus = 'Cannot find candidate list.' return - if self.writelagrfile == False and len(self.nrvir) != 1: + if not self.writelagrfile and len(self.nrvir) != 1: self.filestatus = 'Cant plot, wont write! I do nothing then!' return @@ -527,65 +605,68 @@ def _makeic_button_fired(self): halodata = RSDataReader.RSDataReader(halopath,63,digits=2) allhalos = halodata.get_hosts() idhalo = int(self.haloidselect) - idcand = getcandidatelist(self.candidatefiledir + self.candidatefilename) + idcand = getcandidatelist(candidatefile) idcand = idcand[:,0] - - for index in xrange(0,len(idcand)): - if idcand[index] == idhalo: - nhalo = index - - rvircand = allhalos.ix[idhalo]['rvir'] - mvircand = allhalos.ix[idhalo]['mvir'] - posXcand = allhalos.ix[idhalo]['posX'] - posYcand = allhalos.ix[idhalo]['posY'] - posZcand = allhalos.ix[idhalo]['posZ'] - - ext = "/512Parent/outputs/snapdir_063/snap_063" - header=rsHD.snapshot_header(self.main.headertab.parentsimpath+ext) - - print "------------------------------------------------" - print "Rockstar ID inside parent simulation: ",idhalo - print "------------------------------------------------" - print " Index:",nhalo - print " x-pos:",'{:.2f}'.format(float(allhalos.ix[idhalo]['posX'])), " \ [Mpc/h]" - print " y-pos:",'{:.2f}'.format(float(allhalos.ix[idhalo]['posY'])), " \ [Mpc/h]" - print " z-pos:",'{:.2f}'.format(float(allhalos.ix[idhalo]['posZ'])), " \ [Mpc/h]" - print " virial mass:",'{0:.2e}'.format(float(allhalos.ix[idhalo]['mvir'])/header.hubble),"\ [Msol]" - print " virial radius:",'{:.2f}'.format(float(allhalos.ix[idhalo]['rvir']))," \ [kpc]" - print "------------------------------------------------" + + matches = np.flatnonzero(idcand == idhalo) + if len(matches) == 0: + self.filestatus = 'Halo not in candidate list.' + return + nhalo = int(matches[0]) + + rvircand = allhalos.loc[idhalo]['rvir'] + + header = rsHD.snapshot_header( + self.main.headertab.parentsimpath + PARENT_SNAPSHOT) + + print("------------------------------------------------") + print("Rockstar ID inside parent simulation: ",idhalo) + print("------------------------------------------------") + print(" Index:",nhalo) + print(" x-pos:",'{:.2f}'.format(float(allhalos.loc[idhalo]['posX'])), " \\ [Mpc/h]") + print(" y-pos:",'{:.2f}'.format(float(allhalos.loc[idhalo]['posY'])), " \\ [Mpc/h]") + print(" z-pos:",'{:.2f}'.format(float(allhalos.loc[idhalo]['posZ'])), " \\ [Mpc/h]") + print(" virial mass:",'{:.2e}'.format(float(allhalos.loc[idhalo]['mvir'])/header.hubble),"\\ [Msol]") + print(" virial radius:",'{:.2f}'.format(float(allhalos.loc[idhalo]['rvir']))," \\ [kpc]") + print("------------------------------------------------") self.rockstarhaloid = idhalo - self.haloposx = '{:.2f}'.format(float(allhalos.ix[idhalo]['posX'])) - self.haloposy = '{:.2f}'.format(float(allhalos.ix[idhalo]['posY'])) - self.haloposz = '{:.2f}'.format(float(allhalos.ix[idhalo]['posZ'])) - self.halomvir = '{0:.2e}'.format(float(allhalos.ix[idhalo]['mvir'])/header.hubble) - self.halorvir = '{:.2f}'.format(float(allhalos.ix[idhalo]['rvir'])) + self.haloposx = '{:.2f}'.format(float(allhalos.loc[idhalo]['posX'])) + self.haloposy = '{:.2f}'.format(float(allhalos.loc[idhalo]['posY'])) + self.haloposz = '{:.2f}'.format(float(allhalos.loc[idhalo]['posZ'])) + self.halomvir = '{:.2e}'.format(float(allhalos.loc[idhalo]['mvir'])/header.hubble) + self.halorvir = '{:.2f}'.format(float(allhalos.loc[idhalo]['rvir'])) + + # None of these four blocks depends on Nrvir, so read them once + # rather than once per requested region size. Each POS block is + # ~1.6 GB for a 512^3 parent, so re-reading them per iteration + # dominated the runtime of a multi-nrvir sweep. + snappath = self.main.headertab.parentsimpath + PARENT_SNAPSHOT + icspath = self.main.headertab.parentsimpath + PARENT_ICS + + snapPOS = rsHD.read_block(snappath, "POS ") + # The radius of every parent particle from the candidate centre + # is invariant too -- only the threshold moves with Nrvir. Freed + # immediately, since it is the largest array in play. + R = np.sqrt( + (allhalos.loc[idhalo]['posX'] - snapPOS[:, 0])**2. + + (allhalos.loc[idhalo]['posY'] - snapPOS[:, 1])**2. + + (allhalos.loc[idhalo]['posZ'] - snapPOS[:, 2])**2.) + del snapPOS + + snapIDS = rsHD.read_block(snappath, "ID ") + snapIDSlagr = rs.read_block(icspath, "ID ", doubleprec=False) + snapPOSlagr = rs.read_block(icspath, "POS ", doubleprec=False) for Nrvir in self.nrvir: - print 'Constructing: H' + str(self.haloidselect) + 'NRVIR' + str(int(Nrvir)) + print('Constructing: H' + str(self.haloidselect) + 'NRVIR' + str(int(Nrvir))) Nrvir = float(Nrvir) - ext = "/512Parent/outputs/snapdir_063/snap_063" - snapPOS = rsHD.read_block(self.main.headertab.parentsimpath+ext,"POS ") - dx = allhalos.ix[idhalo]['posX'] - snapPOS[:,0] - dy = allhalos.ix[idhalo]['posY'] - snapPOS[:,1] - dz = allhalos.ix[idhalo]['posZ'] - snapPOS[:,2] - R = np.sqrt(dx**2. + dy**2. + dz**2.) - Rindex = np.where(R < Nrvir*rvircand/1000) - currentpos = snapPOS[Rindex[0]] - del snapPOS, dx, dy, dz - - snapIDS=rsHD.read_block(self.main.headertab.parentsimpath+ext,"ID ") + Rindex = np.where(Nrvir*rvircand/1000 > R) regionIDS = snapIDS[Rindex[0]] - del snapIDS - - extics = "/512Parent/ics/ics" - snapIDSlagr = rs.read_block(self.main.headertab.parentsimpath+extics,"ID ",doubleprec=False) - mask = np.in1d(snapIDSlagr,regionIDS,assume_unique=True) - del snapIDSlagr - snapPOSlagr = rs.read_block(self.main.headertab.parentsimpath+extics,"POS ",doubleprec=False) - lagrPos=snapPOSlagr[mask] + mask = np.in1d(snapIDSlagr, regionIDS, assume_unique=True) + lagrPos = snapPOSlagr[mask] del mask, regionIDS - + CorrectPos(lagrPos[:,0],header.boxsize) CorrectPos(lagrPos[:,1],header.boxsize) CorrectPos(lagrPos[:,2],header.boxsize) @@ -599,9 +680,9 @@ def _makeic_button_fired(self): self.extx=2.0*dx*1.12/header.boxsize self.exty=2.0*dy*1.12/header.boxsize self.extz=2.0*dz*1.12/header.boxsize - + if len(self.nrvir) == 1: - print "Visualising:", 'HALO' + str(self.haloidselect) + 'NRVIR' + str(int(self.nrvir[0])) + print("Visualising:", 'HALO' + str(self.haloidselect) + 'NRVIR' + str(int(self.nrvir[0]))) figure = self.main.display figure.clear() ax = figure.add_subplot(111) @@ -619,7 +700,7 @@ def _makeic_button_fired(self): ytmp = self.ypos xlabel = 'x-pos' ylabel = 'y-pos' - + if self.projopt == 'xz': xbox = self.centx - 0.5*self.extx ybox = self.centz - 0.5*self.extz @@ -629,7 +710,7 @@ def _makeic_button_fired(self): ytmp = self.zpos xlabel = 'x-pos' ylabel = 'z-pos' - + if self.projopt == 'yz': xbox = self.centy - 0.5*self.exty ybox = self.centz - 0.5*self.extz @@ -639,7 +720,7 @@ def _makeic_button_fired(self): ytmp = self.zpos xlabel = 'y-pos' ylabel = 'z-pos' - + ax = self.main.display.axes[0] self.lagrnpart = len(lagrPos[:,1]) self.lagrvol = self.extx*self.exty*self.extz @@ -661,24 +742,19 @@ def _makeic_button_fired(self): self.ypos = lagrPos[:,1] self.zpos = lagrPos[:,2] - if self.writelagrfile == True: - headerfilename = self.lagroutputdir + '/H' + str(self.haloidselect) + 'NRVIR' + str(int(Nrvir)) + '.head' - f1=open(headerfilename,'w') - f1.write('#' + str(self.centx) + '\n') - f1.write('#' + str(self.centy) + '\n') - f1.write('#' + str(self.centz) + '\n') - f1.write('#' + str(self.extx) + '\n') - f1.write('#' + str(self.exty) + '\n') - f1.write('#' + str(self.extz) + '\n') - f1.close() - - filename = self.lagroutputdir + '/H' + str(self.haloidselect) + 'NRVIR' + str(int(Nrvir)) - f2=open(filename,'w') - for iv in xrange(0,len(lagrPos[:,0])): - f2.write(str(lagrPos[iv,0]/header.boxsize)+' '+str(lagrPos[iv,1]/header.boxsize)+' '+ str(lagrPos[iv,2]/header.boxsize)+'\n') - f2.close() - - print "Region constructed!" + if self.writelagrfile: + headerfilename = naming.lagr_header_path( + self.lagroutputdir, self.haloidselect, Nrvir) + writers.write(headerfilename, writers.lagrangian_header( + (self.centx, self.centy, self.centz), + (self.extx, self.exty, self.extz))) + + filename = naming.lagr_region_path( + self.lagroutputdir, self.haloidselect, Nrvir) + writers.write(filename, writers.lagrangian_region( + lagrPos, header.boxsize)) + + print("Region constructed!") def __init__(self, main, **kwargs): @@ -695,17 +771,15 @@ def __init__(self, main, **kwargs): self.accuracy = 0.00001 self.fftfine = True - self.pre_smooth = 3 - self.post_smooth = 3 + self.presmooth = 3 + self.postsmooth = 3 self.smoother = 'gs' self.projopt = 'xy' self.tranfunc = ['eisenstein'] self.cosmologylist = ['PLANCK'] self.laplaceorder = 6 - self.grad_order = 6 - self.lagroutput = str(self.main.headertab.masterpath) + '/' + str(self.toplagr) + '/H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) + self.gradorder = 6 self.resimlagrdir = str(self.main.headertab.datamasterpath) - self.resimlagrfile = '/' + str(self.toplagr) + '/H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) self.masterpath = self.main.headertab.masterpath self.outfilename = 'ics' self.parenticpath = self.main.headertab.parentsimpath + 'RockstarData/ics/' @@ -719,12 +793,10 @@ def __init__(self, main, **kwargs): self.padding = ['7'] self.overlap = ['4'] self.lmax = ['10'] - self.gradorder = 6 self.noutput = 4 #self.parentsimconf = 'ics_example.conf' self.parentsimpath = self.main.headertab.parentsimpath + '512Parent/ics/' #self.parentsimconf = os.path.basename(glob(self.parentsimpath + "*.conf")[0]) - self.resimdir = str(self.main.headertab.datamasterpath) + str(self.toplagr) + 'H' + str(self.haloidselect) + 'NRVIR' + str(self.nrvir) filename = self.parentsimpath + self.parentsimconf self.parentseedlevel = 9 self.parentseednum = 34567 @@ -732,165 +804,5 @@ def __init__(self, main, **kwargs): try: with open(filename): self.parentconfstatus = "Proceed" - except IOError: + except OSError: self.parentconfstatus = "Cannot locate file." - -def constructresimconf(confname,boxlength,zstart,lmin,lTF,lmax,padding,overlap,refcentx,refcenty,refcentz, \ - refextx,refexty,refextz,align,baryons,use2LPT,useLLA,omegam,omegal,omegab,hubble, \ - sigma8,nspec,transfer,seednum,seedlevel,outformat,icfilename,fftfine,accuracy,presmooth,postsmooth, \ - smoother,laplaceorder,gradorder,boxlevel,periodicTFstr,pointfile,boxtype,noutput,haloid,nrvir): - - f = open(confname,'w') - f.write('[setup]' + '\n') - f.write('boxlength = ' + str(boxlength) + '\n') - f.write('zstart = ' + str(zstart) + '\n') - f.write('levelmin = ' + str(lmin) + '\n') - f.write('levelmin_TF = ' + str(lTF) + '\n') - f.write('levelmax = ' + str(lmax) + '\n') - f.write('padding = ' + str(padding) + '\n') - f.write('overlap = ' + str(overlap) + '\n') - f.write('region = ' + str(boxtype) + '\n') - - if boxtype == 'box': - f.write('ref_center = ' + str(refcentx) + ',' + str(refcenty) + ',' + str(refcentz) + '\n') - f.write('ref_extent = ' + str(refextx) + ',' + str(refexty) + ',' + str(refextz) + '\n') - - f.write('region_point_file = ' + str(pointfile) + '\n') - f.write('align_top = ' + str(align) + '\n') - f.write('baryons = ' + str(baryons) + '\n') - f.write('use_2LPT = ' + str(use2LPT) + '\n') - f.write('use_2LLA = ' + str(useLLA) + '\n') - f.write('periodic_TF = ' + str(periodicTFstr) + '\n') - f.write('\n') - f.write('[cosmology]'+ '\n') - f.write('Omega_m = ' + str(omegam) + '\n') - f.write('Omega_L = ' + str(omegal) + '\n') - f.write('Omega_b = ' + str(omegab) + '\n') - f.write('H0 = ' + str(hubble) + '\n') - f.write('sigma_8 = ' + str(sigma8) + '\n') - f.write('nspec = ' + str(nspec) + '\n') - f.write('transfer = ' + str(transfer) + '\n') - f.write('\n') - f.write('[random]' + '\n') - - #diff = int(lmax)+1 - int(lmin) - #print diff - #print int(lmax),int(lmin) - #seednumnew = random.sample(range(10000,99999), diff) - - #seedi = 0 - #print haloid - #mult = nrvir - #f.write('seed[' + str(7) + '] = ' + str(345) + '\n') - #f.write('seed[' + str(8) + '] = ' + str(3456) + '\n') - #f.write('seed[' + str(9) + '] = ' + str(34567) + '\n') - delta = int(nrvir) - for level in range(seedlevel,int(lmax)+1): - delta += delta*2 + 1 - if level != seedlevel: - seeduse = int(haloid) + int(delta) - #seeduse = seednumnew[seedi] - f.write('seed[' + str(level) + '] = ' + str(seeduse) + '\n') - elif level == seedlevel: - f.write('seed[' + str(seedlevel) + '] = ' + str(seednum) + '\n') - #seedi += 1 - - #f.write('seed[7] = ' + str(seeduse) + '\n') - #f.write('seed[8] = ' + str(seeduse) + '\n') - #f.write('seed[9] =' str(34567\n') - #f.write('seed[10] = ' + str(seeduse) + '\n') - #f.write('seed[11] = ' + str(seeduse) + '\n') - - #if int(lmax) == 12: - # f.write('seed[12] = 2600\n') - - - - f.write('\n') - f.write('[output]' + '\n') - - if outformat == 'music': - outformat = 'generic' - - if outformat == 'gadget': - outformat = 'gadget2' - - f.write('format = ' + str(outformat) + '\n') - f.write('filename = ' + str(icfilename) + '\n') - f.write('gadget_num_files = ' + str(noutput) + '\n') - f.write('\n') - f.write('[poisson]' + '\n') - f.write('fft_fine = ' + str(fftfine) + '\n') - f.write('accuracy = ' + str(accuracy) + '\n') - f.write('pre_smooth = ' + str(presmooth) + '\n') - f.write('post_smooth = ' + str(postsmooth) + '\n') - f.write('smoother = ' + str(smoother) + '\n') - f.write('laplace_order = ' + str(laplaceorder) + '\n') - f.write('grad_order = ' + str(gradorder) + '\n') - f.close() - -def constructparentconf(confname,boxlength,zinit,lTF,padding,overlap,refcentx,refcenty,refcentz, \ - refextx,refexty,refextz,align,baryons,use2LPT,useLLA,omegam,omegal,omegab,hubble, \ - sigma8,nspec,transfer,seed,outformat,icfilename,fftfine,accuracy,presmooth,postsmooth, \ - smoother,laplaceorder,gradorder,boxlevel,periodicTFstr,noutput): - - f = open(confname,'w') - f.write('[setup]' + '\n') - f.write('boxlength = ' + str(boxlength) + '\n') - f.write('zstart = ' + str(zinit) + '\n') - f.write('levelmin = ' + str(boxlevel) + '\n') - f.write('levelmin_TF = ' + str(lTF) + '\n') - f.write('levelmax = ' + str(boxlevel) + '\n') - f.write('padding = ' + str(padding) + '\n') - f.write('overlap = ' + str(overlap) + '\n') - f.write('ref_center = ' + str(refcentx) + ', ' + str(refcenty) + ', ' + str(refcentz) + '\n') - f.write('ref_extent = ' + str(refextx) + ', ' + str(refexty) + ', ' + str(refextz) + '\n') - f.write('align_top = ' + str(align) + '\n') - f.write('baryons = ' + str(baryons) + '\n') - f.write('use_2LPT = ' + str(use2LPT) + '\n') - f.write('use_2LLA = ' + str(useLLA) + '\n') - f.write('periodic_TF = ' + str(periodicTFstr) + '\n') - f.write('\n') - f.write('[cosmology]'+ '\n') - f.write('Omega_m = ' + str(omegam) + '\n') - f.write('Omega_L = ' + str(omegal) + '\n') - f.write('Omega_b = ' + str(omegab) + '\n') - f.write('H0 = ' + str(hubble) + '\n') - f.write('sigma_8 = ' + str(sigma8) + '\n') - f.write('nspec = ' + str(nspec) + '\n') - f.write('transfer = ' + str(transfer) + '\n') - f.write('\n') - f.write('[random]' + '\n') - f.write('seed[' + str(boxlevel) + '] = ' + str(seed) + '\n') - - f.write('\n') - f.write('[output]' + '\n') - - if outformat == 'music': - outformat = 'generic' - - if outformat == 'gadget': - outformat = 'gadget2' - - f.write('format = ' + str(outformat) + '\n') - f.write('filename = ' + str(icfilename) + '\n') - f.write('gadget_num_files = ' + str(noutput) + '\n') - f.write('\n') - f.write('[poisson]' + '\n') - f.write('fft_fine = ' + str(fftfine) + '\n') - f.write('accuracy = ' + str(accuracy) + '\n') - f.write('pre_smooth = ' + str(presmooth) + '\n') - f.write('post_smooth = ' + str(postsmooth) + '\n') - f.write('smoother = ' + str(smoother) + '\n') - f.write('laplace_order = ' + str(laplaceorder) + '\n') - f.write('grad_order = ' + str(gradorder) + '\n') - f.close() - -def determineboolstr(boolean): - if boolean == True: - returnstr = 'yes' - elif boolean == False: - returnstr = 'no' - - return returnstr - diff --git a/src/cme/ui/install.py b/src/cme/ui/install.py new file mode 100644 index 0000000..c431812 --- /dev/null +++ b/src/cme/ui/install.py @@ -0,0 +1,132 @@ +from cme import shell +from cme.ui.common import * + + +class InstallFiles(HasTraits): + + view = View(VGroup(HGroup(Item('status', style='readonly', + show_label=False, springy=True)), + HGroup(Item('libpath',show_label=False,springy=True), + Item('makelibdir_button',show_label=False,springy=True)), + HGroup(Item('fftw2dir',show_label=False,springy=True), + Item('installfftw2_button',show_label=False,springy=True)), + HGroup(Item('fftw3dir',show_label=False,springy=True), + Item('installfftw3_button',show_label=False,springy=True)), + HGroup(Item('hdf5dir',show_label=False,springy=True), + Item('installhdf5_button',show_label=False,springy=True)), + HGroup(Item('gsldir',show_label=False,springy=True), + Item('installgsl_button',show_label=False,springy=True)), + HGroup(Item('zlibdir',show_label=False,springy=True), + Item('installzlib_button',show_label=False,springy=True)), + HGroup(Item('szipdir',show_label=False,springy=True), + Item('installszip_button',show_label=False,springy=True)))) + + libpath = Directory + status = Str + fftw2dir = Directory + fftw3dir = Directory + gsldir = Directory + zlibdir = Directory + hdf5dir = Directory + szipdir = Directory + + installfftw2_button = Button('Install FFTW2') + installfftw3_button = Button('Install FFTW3') + installhdf5_button = Button('Install HDF5') + installgsl_button = Button('Install GSL') + installzlib_button = Button('Install ZLIB') + installszip_button = Button('Install SZIP') + makelibdir_button = Button('Make Directory') + + def _libpath_changed(self): + self.fftw2dir = self.libpath + '/fftw2' + self.fftw3dir = self.libpath + '/fftw3' + self.hdf5dir = self.libpath + '/hdf5' + self.gsldir = self.libpath + '/gsl' + self.zlibdir = self.libpath + '/zlib' + self.szipdir = self.libpath + '/szip' + + #: Source trees the install buttons build, and the configure flag sets for + #: each. FFTW ships four variants (single/double precision, with and without + #: the type prefix), so all four are built in turn. + SOURCES = { + "fftw2": ("./lib/installs/fftw-2.1.5/", [ + ["--enable-type-prefix", "--enable-mpi"], + ["--enable-float", "--enable-type-prefix", "--enable-mpi"], + ["--enable-mpi"], + ["--enable-float", "--enable-mpi"], + ]), + "fftw3": ("./lib/installs/fftw-3.3.3/", [ + ["--enable-threads", "--enable-float"], + ["--enable-threads"], + ["--enable-type-prefix", "--enable-threads", "--enable-float"], + ["--enable-type-prefix", "--enable-threads"], + ]), + "hdf5": ("./lib/installs/hdf5-1.8.10/", [ + ["--enable-fortran", "--enable-cxx"], + ]), + "gsl": ("./lib/installs/gsl-1.9/", [[]]), + "szip": ("./lib/installs/szip-2.1/", [ + ["--enable-shared=no", "--enable-static=yes"], + ]), + "zlib": ("./lib/installs/zlib-1.2.8/", [[]]), + } + + def _build(self, name, prefix): + """Configure, build and install one dependency into ``prefix``. + + Each variant runs as argument lists with ``cwd`` set, rather than + ``os.chdir`` plus ``os.system`` on an interpolated string: ``prefix`` + comes from a Directory trait, so a path containing a space previously + split the command and one containing a shell metacharacter executed. + ``run_all`` also stops at the first failure, where the original + ``;``-joined strings ran ``make install`` even after ``configure`` failed. + """ + source, variants = self.SOURCES[name] + if not os.path.isdir(source): + self.status = f"Source tree not found: {source}" + return + + prefix = str(prefix) + self.status = f"Installing {name} into {prefix}..." + for flags in variants: + shell.run_all([ + ["make", "clean"], + ["./configure", f"--prefix={prefix}", *flags], + ["make"], + ["make", "install"], + ["make", "clean"], + ], cwd=source) + self.status = f"{name.upper()} installed: {prefix}" + + def _makelibdir_button_fired(self): + os.makedirs(str(self.libpath), exist_ok=True) + + def _installfftw2_button_fired(self): + self._build("fftw2", self.fftw2dir) + + def _installfftw3_button_fired(self): + self._build("fftw3", self.fftw3dir) + + def _installhdf5_button_fired(self): + self._build("hdf5", self.hdf5dir) + + def _installgsl_button_fired(self): + self._build("gsl", self.gsldir) + + def _installszip_button_fired(self): + self._build("szip", self.szipdir) + + def _installzlib_button_fired(self): + self._build("zlib", self.zlibdir) + + def __init__(self, main, **kwargs): + HasTraits.__init__(self) + self.main = main + self.libpath = self.main.headertab.masterpath + '/lib' + self.fftw2dir = self.libpath + '/fftw2' + self.fftw3dir = self.libpath + '/fftw3' + self.hdf5dir = self.libpath + '/hdf5' + self.gsldir = self.libpath + '/gsl' + self.zlibdir = self.libpath + '/zlib' + diff --git a/mergertree.py b/src/cme/ui/mergertree.py similarity index 79% rename from mergertree.py rename to src/cme/ui/mergertree.py index 1e4aee3..697cadc 100644 --- a/mergertree.py +++ b/src/cme/ui/mergertree.py @@ -1,6 +1,8 @@ -from Common import * import modules.mergertrees.MTCatalogue as MT +from cme.ui.common import * + + class MergerTree(HasTraits): halopath = Directory @@ -34,7 +36,7 @@ class MergerTree(HasTraits): view = View(VGroup(Item(name='halopath',style='readonly'), Item(name='snapshot',label='Snapshot'), - + Group(HGroup(Item(name='haloid',label='Halo ID',style='custom'),springy=True),enabled_when='len(specificid) != 0'), Group(Item(name='specificid',label='Specific ID',width=-80),enabled_when='len(haloid) == 0'), @@ -42,7 +44,7 @@ class MergerTree(HasTraits): Item(name='halo_vary',label='y-axis',springy=True))) ,label='Set Quantities',show_border=True)),enabled_when='use_common == True'), - + HGroup(Group(HGroup(VGroup(Group(Item(name='x_scale',label='x-scale',springy=True), Item(name='y_scale',label='y-scale',springy=True))),label='Set Scale',show_border=True)),enabled_when='use_common == True')), @@ -51,7 +53,7 @@ class MergerTree(HasTraits): Group(Item(name='plot_button',show_label=False,springy=True),enabled_when='len(halo_type) > 0'),enabled_when='len(haloid) >= 1 or specificid != 0'), Group(Item(name='haloidlist',show_label=False,style='readonly',editor=ListEditor(style='readonly',columns=6,rows=4)),label='Halo Sample',show_border=True))) - + def _plot_button_fired(self): if hasattr(self, 'display_points'): figure = self.main.display @@ -73,7 +75,7 @@ def _plot_button_fired(self): tree = cat[0] mainbranch = tree.getMainBranch() self.display_points = ax.plot(mainbranch[self.halo_varx],mainbranch[self.halo_vary],markersize=self.main.markersize,linestyle='-') - + ax.set_xlabel(self.halo_varx) ax.set_ylabel(self.halo_vary) ax.set_xscale(self.x_scale) @@ -82,60 +84,38 @@ def _plot_button_fired(self): wx.CallAfter(self.main.display.canvas.draw) self.jobstatus= "Quantities plotted." - def _halo_varx_changed(self): - self.jobstatus = "Nothing plotted." - if hasattr(self, 'display_points'): - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - - ax = self.main.display.axes[0] - wx.CallAfter(self.main.display.canvas.draw) - - def _halo_vary_changed(self): - self.jobstatus = "Nothing plotted." - if hasattr(self, 'display_points'): - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - - ax = self.main.display.axes[0] - wx.CallAfter(self.main.display.canvas.draw) - - def _haloid_changed(self): - self.jobstatus = "Nothing plotted." - if hasattr(self, 'display_points'): - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - - ax = self.main.display.axes[0] - wx.CallAfter(self.main.display.canvas.draw) + def _invalidate_plot(self): + """Clear the figure when a selection changes, so it cannot go stale. - def _specificid_changed(self): - self.jobstatus = "Nothing plotted." - if hasattr(self, 'display_points'): + Four handlers held byte-identical copies of this, each also fetching an + unused `ax`. + """ + self.jobstatus = "Nothing plotted." + if hasattr(self, 'display_points'): figure = self.main.display figure.clear() - ax = figure.add_subplot(111) + figure.add_subplot(111) + wx.CallAfter(self.main.display.canvas.draw) - ax = self.main.display.axes[0] - wx.CallAfter(self.main.display.canvas.draw) + _halo_varx_changed = _invalidate_plot + _halo_vary_changed = _invalidate_plot + _haloid_changed = _invalidate_plot + _specificid_changed = _invalidate_plot def _x_scale_changed(self): self.jobstatus = "Scale (x) changed." - if hasattr(self, 'display_points'): + if hasattr(self, 'display_points'): # self.display_points[0].remove() self.main.display.axes[0].set_xscale(self.x_scale) wx.CallAfter(self.main.display.canvas.draw) def _y_scale_changed(self): self.jobstatus = "Scale (y) changed." - if hasattr(self, 'display_points'): + if hasattr(self, 'display_points'): #self.display_points[0].remove() self.main.display.axes[0].set_yscale(self.y_scale) wx.CallAfter(self.main.display.canvas.draw) - + def __init__(self, main, **kwargs): HasTraits.__init__(self) self.main = main @@ -145,4 +125,4 @@ def __init__(self, main, **kwargs): self.halopath = self.main.headertab.parentsimpath + 'RockstarData' - + diff --git a/src/cme/writers/__init__.py b/src/cme/writers/__init__.py new file mode 100644 index 0000000..1f3c43f --- /dev/null +++ b/src/cme/writers/__init__.py @@ -0,0 +1,35 @@ +"""Pure text emitters for the configuration files this tool generates. + +Every function here takes plain scalars and returns a string. Nothing touches +the filesystem, the GUI, or global state, so the exact bytes handed to MUSIC, +Gadget and the batch scheduler can be asserted in tests -- which is the only +practical defence against a silently malformed parameter file consuming a +cluster allocation before anyone notices. + +Use :func:`write` when the text needs to land on disk. +""" + +import os + + +def write(path, text): + """Write ``text`` to ``path``, creating parent directories as needed.""" + parent = os.path.dirname(os.path.abspath(path)) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w") as handle: + handle.write(text) + return path + + +def lagrangian_header(centre, extent): + """The ``.head`` sidecar recording a region's centre and extent. + + Six ``#``-prefixed lines, in the order MUSIC's region reader expects. + """ + return "".join(f"#{value}\n" for value in (*centre, *extent)) + + +def lagrangian_region(positions, boxsize): + """The MUSIC region point file: one box-normalised x y z triple per line.""" + return "".join(f"{x / boxsize} {y / boxsize} {z / boxsize}\n" for x, y, z in positions) diff --git a/src/cme/writers/gadget.py b/src/cme/writers/gadget.py new file mode 100644 index 0000000..ef64cad --- /dev/null +++ b/src/cme/writers/gadget.py @@ -0,0 +1,570 @@ +"""P-Gadget3 parameter file, Config.sh and output-list generation. + +Extracted from ``makeparam`` and ``constructconfigsh`` on the Gadget tab, +which built these files with ~110 and ~90 sequential ``f.write`` calls +respectively, interleaved with GUI state. + +Gadget's parameter parser is whitespace-separated ``key value``; keys are +left-justified to a fixed column purely so the file stays readable. +""" + +#: Column width parameter names are padded to. +_KEY_WIDTH = 27 + + +def _params(pairs): + return "".join(f"{key:<{_KEY_WIDTH}}{value}\n" for key, value in pairs) + + +def softenings(boxsize, levelmax): + """Gravitational softening lengths, scaled to the finest refinement level. + + The halo softening is 1/40th of the mean interparticle spacing at + ``levelmax``; each heavier particle type doubles the one before it. Gas is + zero because these are dark-matter-only runs. + + Returns a dict keyed by Gadget's particle type names. + + >>> s = softenings(100.0, 11) + >>> s["Halo"] == 100.0 / 2**11 / 40 + True + >>> s["Disk"] == 2 * s["Halo"] + True + """ + halo = float(boxsize) / 2 ** float(levelmax) / 40.0 + disk = 2 * halo + bulge = 2 * disk + stars = 2 * bulge + bndry = 2 * stars + return { + "Gas": 0.0, + "Halo": halo, + "Disk": disk, + "Bulge": bulge, + "Stars": stars, + "Bndry": bndry, + } + + +def pmgrid_for_levelmax(levelmax): + """The particle-mesh grid size appropriate to a refinement level. + + >>> [pmgrid_for_levelmax(n) for n in (10, 11, 12, 13, 14)] + [256, 256, 512, 512, 1024] + """ + levelmax = int(levelmax) + if levelmax <= 11: + return 256 + if levelmax <= 13: + return 512 + return 1024 + + +def expansion_list(rows): + """The ``ExpansionList`` file: one `` `` line. + + ``rows`` is an iterable of ``(expansion_factor, flag)`` pairs. + """ + return "".join(f"{float(a)} {int(flag)}\n" for a, flag in rows) + + +def param_file( + *, + cosmo, + boxsize, + levelmax, + init_cond_file="./ics_rewrite", + output_dir="./outputs", + energy_file="energy.txt", + info_file="info.txt", + timings_file="timings.txt", + cpu_file="cpu.txt", + restart_file="restart", + snapshot_file_base="snap", + output_list_filename="ExpansionList", + timebin_file="timebin", + ic_format=1, + snap_format=1, + time_limit_cpu=340000, + cpu_time_bet_restart_file=43200, + resubmit_on=0, + resubmit_command="my-scriptfile", + max_mem_size=3500, + part_alloc_factor=2.5, + buffer_size=100, + time_begin=0.0078125, + time_max=1.0, + comoving_integration_on=1, + periodic_boundaries_on=1, + cooling_on=0, + star_formation_on=0, + omega_baryon=0.0, + output_list_on=1, + time_bet_snapshot=0.0, + time_of_first_snapshot=0.0, + time_bet_statistics=0.01, + num_files_per_snapshot=4, + num_files_written_in_parallel=4, + type_of_timestep_criterion=0, + err_tol_int_accuracy=0.012, + courant_fac=0.15, + max_rms_displacement_fac=0.125, + max_size_timestep=0.005, + min_size_timestep=0.0, + init_gas_temp=1000.0, + min_gas_temp=5.0, + type_of_opening_criterion=1, + err_tol_theta=0.6, + err_tol_force_acc=0.0025, + tree_domain_update_frequency=0.01, + des_num_ngb=64, + max_num_ngb_deviation=1, + unit_length_in_cm=3.085678e24, + unit_mass_in_g=1.989e43, + unit_velocity_in_cm_per_s=1e5, + gravity_constant_internal=0, + min_gas_hsml_fractional=0.25, + art_bulk_visc_const=1.0, + include_subfind=False, + err_tol_theta_subfind=0.7, + des_link_ngb=20, + softening=None, +): + """Build a P-Gadget3 parameter file. + + ``cosmo`` is a :class:`cme.cosmology.Cosmology`; ``HubbleParam`` is written + as its dimensionless ``h``. The GUI previously stored H0 in a trait named + ``HubbleParam`` and divided by 100 on the way out, so the value shown in + the interface disagreed with the value written to disk. Taking the + cosmology directly removes that conversion. + + ``omega_baryon`` is separate from ``cosmo.omega_b`` because dark-matter-only + runs set it to zero while still using the cosmology's other parameters. + + ``softening`` defaults to :func:`softenings` for ``boxsize``/``levelmax``. + """ + soft = softenings(boxsize, levelmax) if softening is None else softening + + sections = [ + ( + "%---- Relevant files", + [ + ("InitCondFile", init_cond_file), + ("OutputDir", output_dir), + ("EnergyFile", energy_file), + ("InfoFile", info_file), + ("TimingsFile", timings_file), + ("CpuFile", cpu_file), + ("RestartFile", restart_file), + ("SnapshotFileBase", snapshot_file_base), + ("OutputListFilename", output_list_filename), + ("TimebinFile", timebin_file), + ], + ), + ( + "%---- File formats", + [ + ("ICFormat", ic_format), + ("SnapFormat", snap_format), + ], + ), + ( + "%---- CPU-time limits", + [ + ("TimeLimitCPU", time_limit_cpu), + ("CpuTimeBetRestartFile", cpu_time_bet_restart_file), + ("ResubmitOn", resubmit_on), + ("ResubmitCommand", resubmit_command), + ], + ), + ( + "%----- Memory allocation", + [ + ("MaxMemSize", max_mem_size), + ("PartAllocFactor", part_alloc_factor), + ("BufferSize", buffer_size), + ], + ), + ( + "%---- Characteristics of run", + [ + ("TimeBegin", time_begin), + ("TimeMax", time_max), + ], + ), + ( + "%---- Basic code options that set the type of simulation", + [ + ("ComovingIntegrationOn", comoving_integration_on), + ("PeriodicBoundariesOn", periodic_boundaries_on), + ("CoolingOn", cooling_on), + ("StarformationOn", star_formation_on), + ], + ), + ( + "%---- Cosmological parameters", + [ + ("Omega0", cosmo.omega_m), + ("OmegaLambda", cosmo.omega_l), + ("OmegaBaryon", omega_baryon), + ("HubbleParam", cosmo.h), + ("BoxSize", boxsize), + ], + ), + ( + "%---- Output frequency", + [ + ("OutputListOn", output_list_on), + ("TimeBetSnapshot", time_bet_snapshot), + ("TimeOfFirstSnapshot", time_of_first_snapshot), + ("TimeBetStatistics", time_bet_statistics), + ("NumFilesPerSnapshot", num_files_per_snapshot), + ("NumFilesWrittenInParallel", num_files_written_in_parallel), + ], + ), + ( + "%---- Accuracy of time integration", + [ + ("TypeOfTimestepCriterion", type_of_timestep_criterion), + ("ErrTolIntAccuracy", err_tol_int_accuracy), + ("CourantFac", courant_fac), + ("MaxRMSDisplacementFac", max_rms_displacement_fac), + ("MaxSizeTimestep", max_size_timestep), + ("MinSizeTimestep", min_size_timestep), + ], + ), + ( + "%---- Gas temperatures", + [ + ("InitGasTemp", init_gas_temp), + ("MinGasTemp", min_gas_temp), + ], + ), + ( + "%---- Tree algorithm, force accuracy, domain update frequency", + [ + ("TypeOfOpeningCriterion", type_of_opening_criterion), + ("ErrTolTheta", err_tol_theta), + ("ErrTolForceAcc", err_tol_force_acc), + ("TreeDomainUpdateFrequency", tree_domain_update_frequency), + ], + ), + ( + "%---- Initial density estimate", + [ + ("DesNumNgb", des_num_ngb), + ("MaxNumNgbDeviation", max_num_ngb_deviation), + ], + ), + ( + "%---- System of units", + [ + ("UnitLength_in_cm", unit_length_in_cm), + ("UnitMass_in_g", unit_mass_in_g), + ("UnitVelocity_in_cm_per_s", unit_velocity_in_cm_per_s), + ("GravityConstantInternal", gravity_constant_internal), + ], + ), + ( + "%---- Gravitational softening lengths", + [ + ("MinGasHsmlFractional", min_gas_hsml_fractional), + ], + ), + ( + None, + [ + (f"Softening{t}", soft[t]) + for t in ("Gas", "Halo", "Disk", "Bulge", "Stars", "Bndry") + ], + ), + ( + None, + [ + (f"Softening{t}MaxPhys", soft[t]) + for t in ("Gas", "Halo", "Disk", "Bulge", "Stars", "Bndry") + ], + ), + ( + "%---- non-common", + [ + ("ArtBulkViscConst", art_bulk_visc_const), + ], + ), + ] + + if include_subfind: + sections.append( + ( + None, + [ + ("ErrTolThetaSubfind", err_tol_theta_subfind), + ("DesLinkNgb", des_link_ngb), + ], + ) + ) + + out = [] + for heading, pairs in sections: + if heading: + out.append(heading + "\n") + out.append(_params(pairs)) + out.append("\n") + return "".join(out).rstrip("\n") + "\n" + + +#: Compile-time options, as ``(token, enabled, comment)``. A disabled option is +#: emitted commented out, which is how Gadget's Config.sh expresses "off" while +#: keeping the option discoverable. +_CONFIG_SECTIONS = [ + ( + "Basic operation mode of code", + [ + ("PERIODIC", True, None), + ("COOLING", False, None), + ("SFR", False, None), + ("SINKS", False, None), + ("UNEQUALSOFTENINGS", True, None), + ( + "NUM_THREADS=4", + False, + "Now OpenMP works the same, so don't compile with OpenMP *and* PTHREADS !", + ), + ], + ), + ( + "Kernel Options", + [ + ( + "QUINTIC_KERNEL", + False, + "Implementation of the Morris 1996 quintic spline kernel, requires (3/2)^3 more neighbours !", + ), + ("TWODIMS", False, "Switch for 2D test problems"), + ("ONEDIM", False, "Switch for 1D test problems"), + ], + ), + ( + "Multi-Domain and Top-Level Tree options", + [ + ("MULTIPLEDOMAINS=8", True, None), + ("TOPNODEFACTOR=3.0", False, None), + ("KD_HMAX_ESTIMATE", False, "Alternative way to update HMAX within Tree nodes"), + ], + ), + ( + "Things that are always recommended", + [ + ("PEANOHILBERT", True, None), + ("WALLCLOCK", True, None), + ("MYSORT", True, None), + ("AUTO_SWAP_ENDIAN_READIC", False, "Enables automatic ENDIAN swapping for reading ICs"), + ("WRITE_KEY_FILES", False, "Enables writing key index files"), + ("WRITE_INFO_BLOCK", False, "Enables writing the INFO block"), + ("PERMUTATAION_OPTIMIZATION", False, None), + ( + "PROCESS_TIMES_OF_OUTPUTLIST", + False, + "Chooses the outputtime closest to any global step", + ), + ("SYNCRONIZ_OUTPUT", False, "Writes output only at global time steps"), + ], + ), + ( + "Single/Double Precision", + [ + ("DOUBLEPRECISION", True, None), + ("DOUBLEPRECISION_FFTW", True, None), + ( + "OUTPUT_IN_DOUBLEPRECISION", + False, + "snapshot files will be written in double precision", + ), + ("INPUT_IN_DOUBLEPRECISION", False, None), + ], + ), + ( + "Invariance Test", + [ + ("INVARIANCETEST", False, None), + ("INVARIANCETEST_SIZE1=2", False, None), + ("INVARIANCETEST_SIZE2=6", False, None), + ( + "FLTROUNDOFFREDUCTION", + False, + "enables (expensive!) `double-double' round-off reduction in particle sums", + ), + ( + "SOFTDOUBLEDOUBLE", + False, + "needs to be set if a C++ software implementation of 128bit double-double precision should be used", + ), + ], + ), +] + +#: SUBFIND-related options, emitted enabled or commented out together. +_SUBFIND_SECTION = [ + ("FOF", None), + ("FOF_PRIMARY_LINK_TYPES=2", "2^type for the primary dark matter type"), +] + +_SUBFIND_TRAILER = [ + ("FOF_GROUP_MIN_LEN=32", False, "default is 32"), + ( + "DENSITY_SPLIT_BY_TYPE=1+2+16+32", + False, + "2^type for whch the densities should be calculated seperately", + ), + ("MAX_NGB_CHECK=3", False, "Max numbers of neighbours for sattlepoint detection (default = 2)"), + ( + "SAVE_MASS_TAB", + False, + "Saves the an additional array with the masses of the different components", + ), + ( + "SUBFINDSAVE_PARTICLELISTS", + False, + "Saves also phase-space and type variables parallel to IDs", + ), + ("SO_VEL_DISPERSIONS", False, "computes velocity dispersions for as part of FOF SO-properties"), + ("ORDER_SNAPSHOTS_BY_ID", False, None), + ( + "SAVE_HSML_IN_IC_ORDER", + False, + "will store the hsml-values in the order of the particles in the IC file", + ), + ("ONLY_PRODUCE_HSML_FILES", False, "only carries out density estimate"), + ("KEEP_HSML_AS_GUESS", False, "keep using hsml for gas particles in subfind_density"), + ("LINKLENGTH=0.16", False, "Linkinglength for FoF (default=0.2)"), + ("NO_GAS_CLOUDS", False, "Do not accept pure gaseous substructures"), + ("WRITE_SUB_IN_SNAP_FORMAT", False, "Save subfind results in snap format"), + ("LT_ADD_GAL_TO_SUB=12", False, "Adds optical luminosities in 6 bands to subhalos"), + ( + "DUSTATT=11", + False, + "Includes dust attenuation into the luminosity calculation (using 11 radial bins)", + ), + ( + "OBSERVER_FRAME", + False, + "If defined, use CB07 Observer Frame Luminosities, otherwise CB07 Rest Frame Luminosities", + ), + ("SO_BAR_INFO", False, "Adds temperature, Lx, bfrac, etc to Groups"), + ( + "FSUBFINDCOUNT_BIG_HALOS=1e4", + False, + "Adds extra blocks for Halos with M_TopHat > SUBFIND_COUNT_BIG_HALOS", + ), + ( + "KD_CHOOSE_PSUBFIND_LIMIT", + False, + "Increases the limit for the parallel subfind to the maximum possible", + ), + ( + "KD_ALTERNATIVE_GROUP_SORT", + False, + "Alternative way to sort the Groups/SubGroupe before writing", + ), + ("KD_CHOOSE_LINKING_LENGTH", False, "Special way to estimate the linking length"), + ("SUBFINDREAD_FOF", False, None), + ("SUBFINDCOLLECTIVE_STAGE1", False, None), + ("SUBFINDCOLLECTIVE_STAGE2", False, None), + ("SUBFINDALTERNATIVE_COLLECTIVE", False, None), + ("SUBFINDRESHUFFLE_CATALOGUE", False, None), + ("SUBFINDRESHUFFLE_CATALOGUE_WITH_VORONOI", False, None), + ( + "SUBFINDRESHUFFLE_AND_POTENTIAL", + False, + "needs -DSUBFIND_RESHUFFLE_CATALOGUE and COMPUTE_POTENTIAL_ENERGY", + ), + ( + "SUBFINDDENSITY_AND_POTENTIAL", + False, + "only calculated density and potential and write them into snapshot", + ), +] + + +#: Column trailing comments in Config.sh are aligned to. +_COMMENT_COLUMN = 35 + + +def _option(token, enabled, comment): + line = token if enabled else f"#{token}" + if comment: + # Always leave at least one space, so a token longer than the comment + # column does not end up with `#` jammed against it. + line += " " * max(1, _COMMENT_COLUMN - len(line)) + f"# {comment}" + return line + "\n" + + +def _heading(text): + return f"#--------------------------------------- {text}\n" + + +def config_sh( + *, + pmgrid=512, + include_subfind=False, + fof_secondary_link_types="4+8+16+32", + gridboost=2, + placehighresregion=2, + enlargeregion=1.2, +): + """Build a P-Gadget3 ``Config.sh``. + + Enabled options are emitted bare; disabled ones commented out, which is how + Gadget expresses "off" while keeping the option discoverable. + """ + out = [ + "#!/bin/bash\n", + "##################################################\n", + "# Enable/Disable compile-time options as needed #\n", + "##################################################\n", + ] + + def emit(section): + for token, enabled, comment in section: + out.append(_option(token, enabled, comment)) + + heading, options = _CONFIG_SECTIONS[0] + out.append(_heading(heading)) + emit(options) + + heading, options = _CONFIG_SECTIONS[1] + out.append(_heading(heading)) + emit(options) + + out.append(_heading("TreePM Options")) + emit( + [ + (f"PMGRID={int(pmgrid)}", True, None), + (f"GRIDBOOST={gridboost}", True, None), + ("#ASMTH=1.25", False, None), + ("RCUT=5.25", False, None), + (f"PLACEHIGHRESREGION={placehighresregion}", True, None), + (f"ENLARGEREGION={enlargeregion}", True, None), + ] + ) + + for heading, options in _CONFIG_SECTIONS[2:]: + out.append(_heading(heading)) + emit(options) + + out.append(_heading("On the fly FOF groupfinder")) + for token, comment in _SUBFIND_SECTION: + out.append(_option(token, include_subfind, comment)) + out.append( + _option( + f"FOF_SECONDARY_LINK_TYPES={fof_secondary_link_types}", + include_subfind, + "2^type for the types linked to nearest primaries", + ) + ) + out.append(_option("FOF_GROUP_MIN_LEN=32", False, "default is 32")) + out.append(_option("SUBFIND", include_subfind, None)) + for token, enabled, comment in _SUBFIND_TRAILER[1:]: + out.append(_option(token, enabled, comment)) + + return "".join(out) diff --git a/src/cme/writers/music.py b/src/cme/writers/music.py new file mode 100644 index 0000000..d6f8c8f --- /dev/null +++ b/src/cme/writers/music.py @@ -0,0 +1,268 @@ +"""MUSIC configuration file generation. + +Extracted from the two module-level ``construct*conf`` functions in the ICs +tab, which each built the file with ~50 sequential ``f.write`` calls. + +Keys are left-justified to a fixed column before ``=``. MUSIC's parser is +whitespace-insensitive, so this is cosmetic, but it makes the output diffable. +""" + +#: Column width the ``key = value`` pairs are aligned to. +_KEY_WIDTH = 21 + +#: MUSIC's own names for the output formats the GUI offers. +_OUTPUT_FORMATS = {"music": "generic", "gadget": "gadget2"} + + +def output_format(name): + """Translate a GUI output format name to MUSIC's spelling. + + >>> output_format("gadget"), output_format("music"), output_format("enzo") + ('gadget2', 'generic', 'enzo') + """ + return _OUTPUT_FORMATS.get(str(name), str(name)) + + +def _section(title, pairs): + lines = [f"[{title}]"] + for key, value in pairs: + if value is None: + continue + lines.append(f"{key:<{_KEY_WIDTH}}= {value}") + return "\n".join(lines) + "\n" + + +def _bool(flag): + """MUSIC spells booleans ``yes`` and ``no``. + + Replaces `determineboolstr`, which returned an unbound local for anything + that was not exactly ``True`` or ``False`` -- a numpy bool, for instance. + + >>> _bool(True), _bool(False) + ('yes', 'no') + """ + return "yes" if flag else "no" + + +def refinement_seeds(seed_level, levelmax, halo_id, nrvir, base_seed): + """Per-level random seeds for a zoom refinement hierarchy. + + The coarse level takes ``base_seed``; each finer level takes a seed derived + deterministically from the halo ID and ``nrvir``, so a given halo always + resimulates with the same noise realisation. + + Returns a list of ``(level, seed)`` pairs. + + >>> refinement_seeds(9, 11, 190897, 4, 34567) + [(9, 34567), (10, 190937), (11, 191018)] + """ + seeds = [] + delta = int(nrvir) + for level in range(int(seed_level), int(levelmax) + 1): + # Incremented before use, and unused on the first iteration; preserved + # from the original so seeds stay reproducible against existing runs. + delta += delta * 2 + 1 + if level == int(seed_level): + seeds.append((level, int(base_seed))) + else: + seeds.append((level, int(halo_id) + int(delta))) + return seeds + + +def _cosmology_section(cosmo, transfer): + return _section( + "cosmology", + [ + ("Omega_m", cosmo.omega_m), + ("Omega_L", cosmo.omega_l), + ("Omega_b", cosmo.omega_b), + ("H0", cosmo.H0), + ("sigma_8", cosmo.sigma_8), + ("nspec", cosmo.n_spec), + ("transfer", transfer), + ], + ) + + +def _poisson_section( + fft_fine, accuracy, pre_smooth, post_smooth, smoother, laplace_order, grad_order +): + return _section( + "poisson", + [ + ("fft_fine", _bool(fft_fine)), + ("accuracy", accuracy), + ("pre_smooth", pre_smooth), + ("post_smooth", post_smooth), + ("smoother", smoother), + ("laplace_order", laplace_order), + ("grad_order", grad_order), + ], + ) + + +def _output_section(out_format, filename, num_files): + return _section( + "output", + [ + ("format", output_format(out_format)), + ("filename", filename), + ("gadget_num_files", num_files), + ], + ) + + +def resim_conf( + *, + cosmo, + boxlength, + zstart, + levelmin, + levelmin_TF, + levelmax, + padding, + overlap, + region, + region_point_file, + ref_center=None, + ref_extent=None, + align_top=False, + baryons=False, + use_2LPT=False, + use_2LLA=False, + periodic_TF=True, + transfer="eisenstein", + seed_level, + base_seed, + halo_id, + nrvir, + out_format="gadget", + ic_filename="./ics", + num_files=4, + fft_fine=True, + accuracy=1e-5, + pre_smooth=3, + post_smooth=3, + smoother="gs", + laplace_order=6, + grad_order=6, +): + """Build a MUSIC configuration for a zoom resimulation. + + ``cosmo`` is a :class:`cme.cosmology.Cosmology`. ``ref_center`` and + ``ref_extent`` are 3-tuples, written only for a box region -- an ellipsoid + is defined by the region point file alone. + """ + setup = [ + ("boxlength", boxlength), + ("zstart", zstart), + ("levelmin", levelmin), + ("levelmin_TF", levelmin_TF), + ("levelmax", levelmax), + ("padding", padding), + ("overlap", overlap), + ("region", region), + ] + if str(region) == "box": + setup += [ + ("ref_center", ",".join(str(v) for v in ref_center)), + ("ref_extent", ",".join(str(v) for v in ref_extent)), + ] + setup += [ + ("region_point_file", region_point_file), + ("align_top", _bool(align_top)), + ("baryons", _bool(baryons)), + ("use_2LPT", _bool(use_2LPT)), + ("use_2LLA", _bool(use_2LLA)), + ("periodic_TF", _bool(periodic_TF)), + ] + + seeds = refinement_seeds(seed_level, levelmax, halo_id, nrvir, base_seed) + + return "\n".join( + [ + _section("setup", setup), + _cosmology_section(cosmo, transfer), + _section("random", [(f"seed[{level}]", seed) for level, seed in seeds]), + _output_section(out_format, ic_filename, num_files), + _poisson_section( + fft_fine, + accuracy, + pre_smooth, + post_smooth, + smoother, + laplace_order, + grad_order, + ), + ] + ) + + +def parent_conf( + *, + cosmo, + boxlength, + zstart, + level, + levelmin_TF, + padding, + overlap, + ref_center, + ref_extent, + seed, + align_top=False, + baryons=False, + use_2LPT=False, + use_2LLA=False, + periodic_TF=True, + transfer="eisenstein", + out_format="gadget", + ic_filename="./ics", + num_files=4, + fft_fine=True, + accuracy=1e-5, + pre_smooth=3, + post_smooth=3, + smoother="gs", + laplace_order=6, + grad_order=6, +): + """Build a MUSIC configuration for a uniform-resolution parent box. + + ``level`` sets both ``levelmin`` and ``levelmax``: a parent box is + unrefined, so there is a single seed at that level. + """ + setup = [ + ("boxlength", boxlength), + ("zstart", zstart), + ("levelmin", level), + ("levelmin_TF", levelmin_TF), + ("levelmax", level), + ("padding", padding), + ("overlap", overlap), + ("ref_center", ", ".join(str(v) for v in ref_center)), + ("ref_extent", ", ".join(str(v) for v in ref_extent)), + ("align_top", _bool(align_top)), + ("baryons", _bool(baryons)), + ("use_2LPT", _bool(use_2LPT)), + ("use_2LLA", _bool(use_2LLA)), + ("periodic_TF", _bool(periodic_TF)), + ] + + return "\n".join( + [ + _section("setup", setup), + _cosmology_section(cosmo, transfer), + _section("random", [(f"seed[{int(level)}]", int(seed))]), + _output_section(out_format, ic_filename, num_files), + _poisson_section( + fft_fine, + accuracy, + pre_smooth, + post_smooth, + smoother, + laplace_order, + grad_order, + ), + ] + ) diff --git a/src/cme/writers/submit.py b/src/cme/writers/submit.py new file mode 100644 index 0000000..59375e4 --- /dev/null +++ b/src/cme/writers/submit.py @@ -0,0 +1,140 @@ +"""Batch scheduler submission scripts for PBS/Torque and SLURM. + +Extracted from ``_subscript_button_fired`` on the Gadget tab, which built both +script flavours inline and carried three defects that this module's typing +makes impossible: + +* the SLURM branch concatenated integer traits with strings + (``'#SBATCH -n ' + self.SLURMcores``), raising ``TypeError`` -- so SLURM + submission had never worked; +* the SLURM ``mpirun`` line was built from the *PBS* core count, so a SLURM job + would have launched with the wrong number of ranks; +* enabling SUBFIND switched the PBS command to the ``_sub`` binary and + ``param_sub.txt`` but left the SLURM command pointing at the plain binary, + because both branches of ``_SLURMcores_changed`` were identical. + +Rank counts are formatted with ``int()`` here, and one shared +:func:`mpirun_command` serves both schedulers. +""" + +#: Gadget's restart flag for "run FOF/SUBFIND on an existing snapshot". +SUBFIND_RESTART_FLAG = 3 + + +def mpirun_command( + *, + ranks, + include_subfind=False, + binary="P-Gadget3", + param="param.txt", + out="OUTPUT", + err="ERROR", + subfind_snapshot=63, +): + """The ``mpirun`` line that launches Gadget. + + With ``include_subfind`` the ``_sub`` binary and ``param_sub.txt`` are used, + the restart flag and snapshot number are appended, and the log filenames + gain a ``sub`` suffix -- matching the existing convention. + + >>> mpirun_command(ranks=64) + 'mpirun -np 64 ./P-Gadget3 ./param.txt 1>OUTPUT 2>ERROR' + >>> mpirun_command(ranks=64, include_subfind=True) + 'mpirun -np 64 ./P-Gadget3_sub ./param_sub.txt 3 63 1>OUTPUTsub 2>ERRORsub' + """ + if include_subfind: + stem, ext = param.rsplit(".", 1) if "." in param else (param, None) + param = f"{stem}_sub" + (f".{ext}" if ext else "") + binary = f"{binary}_sub" + args = f" {SUBFIND_RESTART_FLAG} {int(subfind_snapshot)}" + out, err = f"{out}sub", f"{err}sub" + else: + args = "" + + return f"mpirun -np {int(ranks)} ./{binary} ./{param}{args} 1>{out} 2>{err}" + + +def slurm_script( + *, + job_name, + work_dir, + execute, + cores, + minutes, + memory_mb, + queue, + email=None, +): + """A SLURM batch script. + + ``memory_mb`` is per CPU, matching ``--mem-per-cpu``. + """ + lines = [ + "#!/bin/bash", + f"#SBATCH -n {int(cores)}", + f"#SBATCH -o {job_name}.o%j", + f"#SBATCH -e {job_name}.e%j", + f"#SBATCH -t {int(minutes)}", + f"#SBATCH -p {queue}", + f"#SBATCH --mem-per-cpu={int(memory_mb)}", + ] + if email: + lines += [ + f"#SBATCH --mail-user={email}", + "#SBATCH --mail-type=begin", + "#SBATCH --mail-type=end", + ] + lines += [ + f"#SBATCH -J {job_name}", + "", + f"cd {work_dir}", + "", + execute, + "", + ] + return "\n".join(lines) + + +def pbs_script( + *, + job_name, + work_dir, + execute, + cores_per_node, + nodes, + queue, + email=None, + extra_flags="", + extra_lines="", +): + """A PBS/Torque batch script.""" + lines = [ + "#!/bin/csh", + "#PBS -k eo", + f"#PBS -l nodes={int(nodes)}:ppn={int(cores_per_node)}", + f"#PBS -N {job_name}", + ] + if email: + lines.append(f"#PBS -M {email}") + lines += [ + f"#PBS -o {work_dir}/{job_name}.out", + f"#PBS -e {work_dir}/{job_name}.err", + f"#PBS -q {queue}", + ] + if extra_flags: + lines.append(extra_flags) + lines.append("") + if extra_lines: + lines += [extra_lines, ""] + lines += [f"cd {work_dir}", "", execute, ""] + return "\n".join(lines) + + +def script(scheduler, **kwargs): + """Dispatch to :func:`slurm_script` or :func:`pbs_script` by name.""" + scheduler = str(scheduler).upper() + if scheduler == "SLURM": + return slurm_script(**kwargs) + if scheduler == "PBS": + return pbs_script(**kwargs) + raise ValueError(f"unknown scheduler {scheduler!r}; expected 'SLURM' or 'PBS'") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..8c0c5e2 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,156 @@ +import os + +import pytest + +from cme import config + +REPO_CLUSTERS = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "clusters.toml" +) + +pytestmark = pytest.mark.skipif(config.tomllib is None, reason="no TOML parser available") + + +@pytest.fixture +def cfg(): + return config.load(REPO_CLUSTERS) + + +class TestLoad: + def test_reads_the_repository_profiles(self, cfg): + assert set(cfg["clusters"]) >= { + "local", + "bigbang", + "odyssey", + "spacebase", + "antares", + "barrine", + } + + def test_falls_back_to_a_local_profile_when_no_file_is_found(self, monkeypatch): + # With no config file and no override, the application must still start. + monkeypatch.setattr(config, "config_path", lambda: None) + fallback = config.load() + assert list(fallback["clusters"]) == ["local"] + assert fallback["default"] == "local" + + def test_env_override_selects_the_file(self, monkeypatch): + monkeypatch.setenv("CME_CLUSTERS", REPO_CLUSTERS) + assert config.config_path() == REPO_CLUSTERS + + def test_env_override_pointing_nowhere_is_ignored(self, monkeypatch): + monkeypatch.setenv("CME_CLUSTERS", "/nonexistent/clusters.toml") + assert config.config_path() is None + + def test_profile_names_are_sorted(self, cfg): + names = config.profile_names(cfg) + assert names == sorted(names) + + +class TestDetect: + @pytest.mark.parametrize( + "hostname,expected", + [ + ("bigbang.mit.edu", "bigbang"), + ("rclogin13.rc.fas.harvard.edu", "odyssey"), + ("spacebase", "spacebase"), + ("antares", "antares"), + ("barrine", "barrine"), + ], + ) + def test_recognises_the_hostnames_the_if_chain_handled(self, cfg, hostname, expected): + assert config.detect(cfg, hostname=hostname) == expected + + def test_unknown_hostname_falls_back_to_the_default(self, cfg): + assert config.detect(cfg, hostname="some-laptop.local") == "local" + + +class TestResolve: + def test_placeholders_are_expanded(self, cfg): + paths = config.resolve("bigbang", config=cfg, username="alice") + assert paths["master"] == "/bigbang/data/alice" + assert paths["gadget"] == "/bigbang/data/alice/lib/P-Gadget3" + assert paths["music"] == "/bigbang/data/alice/lib/music" + assert paths["data"] == "/bigbang/data/AnnaGroup/caterpillar" + + def test_no_placeholder_survives_resolution(self, cfg): + for name in config.profile_names(cfg): + paths = config.resolve(name, config=cfg, username="alice") + for key in config.PATH_KEYS: + assert "{" not in paths[key], (name, key) + + def test_home_root_puts_the_user_below_it(self, cfg): + # /n/home01 contains per-user homes, so alice's is /n/home01/alice. + paths = config.resolve("odyssey", config=cfg, username="alice") + assert paths["master"] == "/n/home01/alice" + + def test_local_profile_uses_the_real_home_directory(self, cfg): + paths = config.resolve("local", config=cfg, username="alice") + assert paths["master"] == os.path.expanduser("~") + + def test_profile_username_is_used_when_none_is_given(self): + # A site may issue a login different from the local one. The shipped + # profiles deliberately do not set this -- it belongs in a personal + # override -- so the feature is exercised against an inline config. + cfg = { + "default": "site", + "clusters": { + "site": { + "hostnames": [], + "home_root": "/scratch", + "gadget": "{master}/lib/P-Gadget3", + "username": "site-login", + } + }, + } + resolved = config.resolve("site", config=cfg) + assert resolved["username"] == "site-login" + assert resolved["gadget"] == "/scratch/site-login/lib/P-Gadget3" + + # An explicit username still wins. + assert config.resolve("site", config=cfg, username="alice")["username"] == "alice" + + def test_shipped_profiles_do_not_pin_a_login_name(self, cfg): + # clusters.toml is shared, so {user} must resolve to whoever is running. + for name in config.profile_names(cfg): + assert config.resolve(name, config=cfg, username="alice")["username"] == "alice" + + def test_scheduler_settings_are_exposed(self, cfg): + assert config.resolve("odyssey", config=cfg)["scheduler"] == "SLURM" + assert config.resolve("odyssey", config=cfg)["queue"] == "itc_cluster" + assert config.resolve("bigbang", config=cfg)["scheduler"] == "PBS" + + def test_torque_profile_carries_its_mpi_setup_line(self, cfg): + extra = config.resolve("bigbang", config=cfg)["extra_lines"] + assert "openmpi-setup.sh" in extra + + def test_scheduler_defaults_to_pbs(self, cfg): + assert config.resolve("local", config=cfg)["scheduler"] == "PBS" + + def test_rejects_an_unknown_profile(self, cfg): + with pytest.raises(ValueError, match="unknown cluster"): + config.resolve("nonexistent", config=cfg) + + +class TestNoPersonalDataInProfiles: + """The if-chain this replaced hardcoded one author's paths and addresses.""" + + def test_profiles_contain_no_email_addresses(self): + with open(REPO_CLUSTERS) as handle: + assert "@" not in handle.read() + + def test_paths_differ_only_by_the_running_user(self, cfg): + """No third party's login or home directory can be baked into a path. + + Resolving the same profile as two different users must differ only where + the username appears. Anything constant across both is site structure; + anything varying is correctly attributed to the caller. + """ + for name in config.profile_names(cfg): + alice = config.resolve(name, config=cfg, username="alice") + bob = config.resolve(name, config=cfg, username="bob") + for key in config.PATH_KEYS: + assert alice[key].replace("alice", "") == bob[key].replace("bob", ""), ( + name, + key, + ) diff --git a/tests/test_cosmology.py b/tests/test_cosmology.py new file mode 100644 index 0000000..0ff202d --- /dev/null +++ b/tests/test_cosmology.py @@ -0,0 +1,77 @@ +import pytest + +from cme import cosmology + + +class TestLookup: + def test_get_is_case_insensitive(self): + assert cosmology.get("planck") is cosmology.PLANCK + assert cosmology.get("PLANCK") is cosmology.PLANCK + assert cosmology.get("WMAP7") is cosmology.WMAP7 + + def test_rejects_unknown_name(self): + with pytest.raises(ValueError, match="unknown cosmology"): + cosmology.get("WMAP11") + + def test_every_name_offered_by_the_gui_resolves(self): + # These are the values in the CheckListEditor on the ICs and Gadget tabs. + for name in ("WMAP1", "WMAP3", "WMAP5", "WMAP7", "WMAP9", "PLANCK"): + assert cosmology.get(name).name == name + + +class TestPlanckCrossCheck: + """PLANCK is the row that can be verified against the existing source. + + The Gadget tab hardcoded these same values as its defaults, so they are a + reliable check that this table follows the same conventions as the + `grifflib.cosmoconstant` it replaces. + """ + + def test_matches_the_hardcoded_gadget_defaults(self): + planck = cosmology.PLANCK + assert planck.omega_m == 0.3175 + assert planck.omega_l == 0.6825 + assert planck.h == 0.6711 + + def test_omega_b_is_consistent_with_planck_2013_omega_b_h_squared(self): + # Planck 2013 XVI reports Omega_b h^2 = 0.02205. + assert cosmology.PLANCK.omega_b == pytest.approx(0.02205 / cosmology.PLANCK.h**2, abs=1e-3) + + +class TestInternalConsistency: + @pytest.mark.parametrize("name", sorted(cosmology.COSMOLOGIES)) + def test_flat_universe(self, name): + c = cosmology.get(name) + assert c.omega_m + c.omega_l == pytest.approx(1.0, abs=1e-3) + + @pytest.mark.parametrize("name", sorted(cosmology.COSMOLOGIES)) + def test_parameters_are_physically_plausible(self, name): + c = cosmology.get(name) + assert 0.0 < c.omega_b < c.omega_m + assert 0.5 < c.h < 1.0 + assert 0.5 < c.sigma_8 < 1.2 + assert 0.9 < c.n_spec < 1.1 + + @pytest.mark.parametrize("name", sorted(cosmology.COSMOLOGIES)) + def test_every_set_cites_a_reference(self, name): + assert cosmology.get(name).reference + + +class TestUnitConvention: + def test_H0_is_in_km_per_s_per_mpc(self): + assert pytest.approx(67.11) == cosmology.PLANCK.H0 + + def test_as_tuple_returns_H0_not_h(self): + # The Gadget tab writes `HubbleParam` as this value / 100, so the tuple + # must carry H0. Returning h here would emit HubbleParam = 0.006711. + _, _, _, hubble, _, _ = cosmology.PLANCK.as_tuple() + assert hubble == pytest.approx(67.11) + assert hubble / 100 == pytest.approx(cosmology.PLANCK.h) + + def test_cosmoconstant_shim_matches_the_old_six_tuple_order(self): + omega_m, omega_l, omega_b, hubble, sigma_8, n_spec = cosmology.cosmoconstant("PLANCK") + assert (omega_m, omega_l, omega_b) == (0.3175, 0.6825, 0.0490) + assert (hubble, sigma_8, n_spec) == pytest.approx((67.11, 0.8344, 0.9624)) + + def test_default_is_planck(self): + assert cosmology.DEFAULT is cosmology.PLANCK diff --git a/tests/test_naming.py b/tests/test_naming.py new file mode 100644 index 0000000..1bec385 --- /dev/null +++ b/tests/test_naming.py @@ -0,0 +1,181 @@ +import pytest + +from cme import naming + + +class TestRegionCode: + def test_known_regions(self): + assert naming.region_code("box") == "B" + assert naming.region_code("ellipsoid") == "E" + + def test_is_case_insensitive(self): + # The GUI passes Enum values through .upper() in some tabs, not others. + assert naming.region_code("Ellipsoid") == "E" + assert naming.region_code("BOX") == "B" + + def test_rejects_unknown_region(self): + with pytest.raises(ValueError, match="unknown region"): + naming.region_code("sphere") + + +class TestRunDirName: + def test_matches_the_established_convention(self): + # A real Caterpillar run directory, as it appears in the screenshots. + assert ( + naming.run_dir_name( + halo_id=190897, + region="ellipsoid", + padding=7, + levelmin=7, + levelmax=11, + overlap=4, + nrvir=4, + ) + == "H190897_BE_Z127_P7_LN7_LX11_O4_NV4" + ) + + def test_box_region_uses_b(self): + name = naming.run_dir_name( + halo_id=1930, + region="box", + padding=8, + levelmin=7, + levelmax=12, + overlap=4, + nrvir=3, + ) + assert name == "H1930_BB_Z127_P8_LN7_LX12_O4_NV3" + + def test_accepts_strings_as_well_as_ints(self): + # The ICs and Gadget tabs supply these as strings from a + # CheckListEditor; the contamination and halo tabs supply ints. + from_strings = naming.run_dir_name( + halo_id="190897", + region="ellipsoid", + padding="7", + levelmin="7", + levelmax="11", + overlap="4", + nrvir="4", + ) + from_ints = naming.run_dir_name( + halo_id=190897, + region="ellipsoid", + padding=7, + levelmin=7, + levelmax=11, + overlap=4, + nrvir=4, + ) + assert from_strings == from_ints + + def test_zinit_defaults_to_127_but_is_honoured(self): + # The contamination and halo tabs previously hardcoded _Z127, so a + # non-default zinit silently pointed them at a directory that the ICs + # tab had never created. + assert "_Z127_" in naming.run_dir_name( + halo_id=1, + region="box", + padding=7, + levelmin=7, + levelmax=11, + overlap=4, + nrvir=1, + ) + assert "_Z63_" in naming.run_dir_name( + halo_id=1, + region="box", + padding=7, + levelmin=7, + levelmax=11, + overlap=4, + nrvir=1, + zinit=63, + ) + + +class TestParseRunDirName: + def test_round_trips(self): + kwargs = dict( + halo_id=241932, + region="box", + padding=9, + levelmin=8, + levelmax=13, + overlap=5, + nrvir=7, + zinit=63, + ) + parsed = naming.parse_run_dir_name(naming.run_dir_name(**kwargs)) + assert parsed == kwargs + + def test_returns_none_for_unrelated_names(self): + assert naming.parse_run_dir_name("outputs") is None + assert naming.parse_run_dir_name("H190897_BE_Z127") is None + + +class TestSnapshotIndex: + @pytest.mark.parametrize( + "snapnum,expected", + [(0, "000"), (5, "005"), (63, "063"), (64, "064"), (127, "127"), (255, "255")], + ) + def test_pads_to_three_digits(self, snapnum, expected): + assert naming.snapshot_index(snapnum) == expected + + def test_regression_single_digit_snapshots(self): + # The inline form was "groups_0" + str(snapnum), which produced + # "groups_05" for snapshot 5. Gadget writes "groups_005", so every + # existence check against a single-digit snapshot reported "not found". + assert naming.groups_dir("/out", 5).endswith("groups_005") + assert naming.snapshot_dir("/out", 5).endswith("snapdir_005") + + def test_regression_three_digit_snapshots(self): + # Likewise the zoom tabs default to snapshot 255, where the inline + # form produced "snapdir_0255". + assert naming.snapshot_dir("/out", 255).endswith("snapdir_255") + + +class TestPaths: + RUN = dict( + region="ellipsoid", + padding=7, + levelmin=7, + levelmax=11, + overlap=4, + nrvir=4, + ) + + def test_run_dir(self): + assert naming.run_dir("/data", 190897, **self.RUN) == ( + "/data/halos/H190897/H190897_BE_Z127_P7_LN7_LX11_O4_NV4" + ) + + def test_outputs_and_children(self): + outputs = naming.outputs_dir("/data", 190897, **self.RUN) + assert outputs.endswith("/H190897_BE_Z127_P7_LN7_LX11_O4_NV4/outputs") + assert naming.groups_dir(outputs, 63).endswith("/outputs/groups_063") + assert naming.snapshot_base(outputs, 63).endswith("/outputs/snapdir_063/snap_063") + + def test_rockstar_dir(self): + assert naming.rockstar_dir("/data", 190897, **self.RUN).endswith( + "/H190897_BE_Z127_P7_LN7_LX11_O4_NV4/rockstardata" + ) + + def test_trailing_slash_on_base_is_normalised(self): + # The inline form was `self.gadpath + 'halos/H'`, which silently + # produced ".../datahalos/H190897" when the path trait had no + # trailing slash. + assert naming.run_dir("/data/", 190897, **self.RUN) == naming.run_dir( + "/data", 190897, **self.RUN + ) + + def test_lagrangian_paths(self): + assert naming.lagr_region_path("/d/ics/lagr", 190897, 4) == ("/d/ics/lagr/H190897NRVIR4") + assert naming.lagr_header_path("/d/ics/lagr", 190897, 4) == ( + "/d/ics/lagr/H190897NRVIR4.head" + ) + + def test_lagrangian_paths_coerce_nrvir(self): + # The ICs tab passes nrvir as a one-element list of strings; formatting + # it without coercion produced "H190897NRVIR['4']". + assert naming.lagr_region_path("/d", 190897, "4") == "/d/H190897NRVIR4" diff --git a/tests/test_science_candidates.py b/tests/test_science_candidates.py new file mode 100644 index 0000000..a064415 --- /dev/null +++ b/tests/test_science_candidates.py @@ -0,0 +1,289 @@ +import numpy as np +import pytest + +from cme.science import candidates +from cme.science.candidates import ExclusionZone + + +def catalogue(rows): + """Build a host catalogue from (id, mvir, rvir, x, y, z) tuples.""" + keys = ("id", "mvir", "rvir", "posX", "posY", "posZ") + return {k: [row[i] for row in rows] for i, k in enumerate(keys)} + + +MW = 1e12 # comfortably inside the default 7e11 - 7e12 base sample +RANGE = (7e11, 7e12) + + +class TestExclusionZone: + def test_requires_exactly_one_mass_specification(self): + with pytest.raises(ValueError, match="exactly one"): + ExclusionZone(distance=1.0) + with pytest.raises(ValueError, match="exactly one"): + ExclusionZone(distance=1.0, mass=1e12, mass_multiple=0.5) + + def test_is_relative_reflects_which_was_given(self): + assert ExclusionZone(distance=1.0, mass_multiple=0.5).is_relative + assert not ExclusionZone(distance=1.0, mass=1e12).is_relative + + +class TestMassRange: + def test_selects_only_haloes_inside_the_range(self): + cat = catalogue( + [ + (1, 1e11, 100.0, 10.0, 10.0, 10.0), # too light + (2, MW, 200.0, 30.0, 30.0, 30.0), # in range + (3, 1e14, 900.0, 50.0, 50.0, 50.0), # too heavy + ] + ) + out = candidates.select(cat, RANGE, [], hubble=1.0) + assert list(out["id"]) == [2] + + def test_range_is_exclusive_at_both_ends(self): + cat = catalogue( + [ + (1, 7e11, 100.0, 10.0, 10.0, 10.0), + (2, 7e12, 100.0, 30.0, 30.0, 30.0), + ] + ) + assert len(candidates.select(cat, RANGE, [], hubble=1.0)) == 0 + + def test_returns_an_empty_array_not_a_zero_row(self): + # AUDIT 2.2.3: the original seeded its results with np.zeros((1, 6)), so + # every selection carried a phantom zero-mass candidate at the origin. + cat = catalogue([(1, 1e14, 900.0, 10.0, 10.0, 10.0)]) + out = candidates.select(cat, RANGE, [], hubble=1.0) + assert len(out) == 0 + assert out["mvir"].sum() == 0.0 # because there are no rows at all + assert 0 not in list(out["id"]) + + +class TestHubbleConversion: + def test_masses_are_converted_before_the_range_test(self): + # 5e12 Msol/h is 1e13 Msol at h=0.5, which is outside the range. + cat = catalogue([(1, 5e12, 200.0, 10.0, 10.0, 10.0)]) + assert len(candidates.select(cat, RANGE, [], hubble=1.0)) == 1 + assert len(candidates.select(cat, RANGE, [], hubble=0.5)) == 0 + + def test_distances_are_converted_before_the_zone_test(self): + # Separation is 2 Mpc/h. At h=1.0 that is 2 Mpc and clears a 1.5 Mpc + # zone; at h=0.5 it is 4 Mpc and clears it by more. + cat = catalogue( + [ + (1, MW, 200.0, 10.0, 10.0, 10.0), + (2, 1e14, 900.0, 12.0, 10.0, 10.0), + ] + ) + zone = [ExclusionZone(distance=3.0, mass=1e13)] + assert len(candidates.select(cat, RANGE, zone, hubble=1.0)) == 0 + assert len(candidates.select(cat, RANGE, zone, hubble=0.5)) == 1 + + +class TestAbsoluteExclusionZones: + def test_rejects_a_candidate_with_a_massive_neighbour_inside_the_zone(self): + cat = catalogue( + [ + (1, MW, 200.0, 10.0, 10.0, 10.0), + (2, 1e14, 900.0, 12.0, 10.0, 10.0), # 2 Mpc away + ] + ) + zones = [ExclusionZone(distance=4.0, mass=7e13)] + assert len(candidates.select(cat, RANGE, zones, hubble=1.0)) == 0 + + def test_accepts_when_the_neighbour_is_beyond_the_zone(self): + cat = catalogue( + [ + (1, MW, 200.0, 10.0, 10.0, 10.0), + (2, 1e14, 900.0, 20.0, 10.0, 10.0), # 10 Mpc away + ] + ) + zones = [ExclusionZone(distance=4.0, mass=7e13)] + assert list(candidates.select(cat, RANGE, zones, hubble=1.0)["id"]) == [1] + + def test_accepts_when_the_neighbour_is_below_the_mass_threshold(self): + cat = catalogue( + [ + (1, MW, 200.0, 10.0, 10.0, 10.0), + # Close, but far below the zone's mass -- and below the base sample + # range too, so it is not itself a candidate. + (2, 1e11, 90.0, 11.0, 10.0, 10.0), + ] + ) + zones = [ExclusionZone(distance=4.0, mass=7e13)] + assert list(candidates.select(cat, RANGE, zones, hubble=1.0)["id"]) == [1] + + def test_all_zones_must_pass(self): + cat = catalogue( + [ + (1, MW, 200.0, 10.0, 10.0, 10.0), + (2, 8e12, 300.0, 12.0, 10.0, 10.0), # violates the near zone only + ] + ) + near = ExclusionZone(distance=3.0, mass=7e12) + far = ExclusionZone(distance=4.0, mass=7e13) + assert len(candidates.select(cat, RANGE, [far], hubble=1.0)) == 1 + assert len(candidates.select(cat, RANGE, [near, far], hubble=1.0)) == 0 + + def test_distance_is_measured_in_three_dimensions(self): + # (3, 4, 0) is 5 Mpc away, not 3 or 4. + cat = catalogue( + [ + (1, MW, 200.0, 10.0, 10.0, 10.0), + (2, 1e14, 900.0, 13.0, 14.0, 10.0), + ] + ) + assert ( + len(candidates.select(cat, RANGE, [ExclusionZone(distance=4.9, mass=7e13)], hubble=1.0)) + == 1 + ) + assert ( + len(candidates.select(cat, RANGE, [ExclusionZone(distance=5.1, mass=7e13)], hubble=1.0)) + == 0 + ) + + +class TestRelativeExclusionZones: + def test_excludes_the_candidate_itself(self): + # A halo is zero distance from itself and always satisfies + # "at least half my mass", so without self-exclusion nothing would ever + # be selected. + cat = catalogue([(1, MW, 200.0, 10.0, 10.0, 10.0)]) + zones = [ExclusionZone(distance=1.4, mass_multiple=0.5)] + assert list(candidates.select(cat, RANGE, zones, hubble=1.0)["id"]) == [1] + + def test_rejects_a_comparable_mass_neighbour(self): + cat = catalogue( + [ + (1, MW, 200.0, 10.0, 10.0, 10.0), + (2, 0.8 * MW, 190.0, 11.0, 10.0, 10.0), # 1 Mpc, > half the mass + ] + ) + zones = [ExclusionZone(distance=1.4, mass_multiple=0.5)] + assert len(candidates.select(cat, RANGE, zones, hubble=1.0)) == 0 + + def test_ignores_a_much_lighter_neighbour(self): + cat = catalogue( + [ + (1, MW, 200.0, 10.0, 10.0, 10.0), + (2, 0.1 * MW, 90.0, 11.0, 10.0, 10.0), # 1 Mpc, well under half + ] + ) + zones = [ExclusionZone(distance=1.4, mass_multiple=0.5)] + assert list(candidates.select(cat, RANGE, zones, hubble=1.0)["id"]) == [1] + + def test_threshold_scales_with_each_candidate(self): + # The 2e12 halo is more than half of 3e12 but the 1e12 halo is not, + # so only the heavier candidate is rejected by its neighbour. + cat = catalogue( + [ + (1, 1e12, 200.0, 10.0, 10.0, 10.0), + (2, 3e12, 300.0, 40.0, 40.0, 40.0), + (3, 2e12, 250.0, 41.0, 40.0, 40.0), + ] + ) + zones = [ExclusionZone(distance=1.4, mass_multiple=0.5)] + out = candidates.select(cat, RANGE, zones, hubble=1.0) + assert list(out["id"]) == [1] + + +class TestDefaultCaterpillarCriteria: + """The criteria the GUI shipped as defaults, as one integration case.""" + + ZONES = [ + ExclusionZone(distance=1.4, mass_multiple=0.5), + ExclusionZone(distance=3.0, mass=7e12), + ExclusionZone(distance=4.0, mass=7e13), + ] + + def test_an_isolated_milky_way_analogue_is_selected(self): + cat = catalogue( + [ + (100, 1.5e12, 250.0, 50.0, 50.0, 50.0), + (200, 1e14, 900.0, 70.0, 50.0, 50.0), # 20 Mpc: clears all zones + ] + ) + out = candidates.select(cat, RANGE, self.ZONES, hubble=1.0) + assert list(out["id"]) == [100] + + def test_a_cluster_neighbour_at_3_mpc_disqualifies(self): + cat = catalogue( + [ + (100, 1.5e12, 250.0, 50.0, 50.0, 50.0), + (200, 1e14, 900.0, 53.0, 50.0, 50.0), # inside the 4 Mpc zone + ] + ) + assert len(candidates.select(cat, RANGE, self.ZONES, hubble=1.0)) == 0 + + def test_returns_columns_in_the_documented_order(self): + cat = catalogue([(100, 1.5e12, 250.0, 50.0, 51.0, 52.0)]) + out = candidates.select(cat, RANGE, self.ZONES, hubble=1.0) + assert out.dtype.names == candidates.COLUMNS + assert out[0]["posY"] == 51.0 + assert out[0]["rvir"] == 250.0 + + def test_rvir_is_not_hubble_scaled(self): + # rvir passes through in kpc/h as Rockstar reports it; only mvir and + # the distances are converted. + cat = catalogue([(100, 1.5e12, 250.0, 50.0, 50.0, 50.0)]) + out = candidates.select(cat, RANGE, [], hubble=0.6711) + assert out[0]["rvir"] == 250.0 + + +class TestCounts: + def test_reports_base_sample_and_per_zone_populations(self): + cat = catalogue( + [ + (1, 1e12, 200.0, 10.0, 10.0, 10.0), + (2, 2e12, 250.0, 20.0, 20.0, 20.0), + (3, 1e13, 500.0, 30.0, 30.0, 30.0), + (4, 1e14, 900.0, 40.0, 40.0, 40.0), + ] + ) + zones = [ + ExclusionZone(distance=1.4, mass_multiple=0.5), + ExclusionZone(distance=3.0, mass=7e12), + ExclusionZone(distance=4.0, mass=7e13), + ] + result = candidates.counts(cat, RANGE, zones, hubble=1.0) + assert result["base_sample"] == 2 # the 1e12 and 2e12 haloes + assert result["above_zone2"] == 2 # 1e13 and 1e14 + assert result["above_zone3"] == 1 # 1e14 only + assert "above_zone1" not in result # relative zones have no fixed count + + +class TestSerialisation: + def test_round_trips_through_the_candidates_dat_format(self): + cat = catalogue([(190897, 1.5e12, 250.0, 50.1, 51.2, 52.3)]) + out = candidates.select(cat, RANGE, [], hubble=1.0) + text = candidates.to_text(out) + + assert text.startswith("# ID M Rvir x y z\n") + values = text.splitlines()[1].split() + assert int(float(values[0])) == 190897 + assert float(values[1]) == pytest.approx(1.5e12) + assert float(values[3]) == pytest.approx(50.1) + + def test_empty_selection_writes_only_a_header(self): + empty = candidates.select(catalogue([]), RANGE, [], hubble=1.0) + assert candidates.to_text(empty) == "# ID M Rvir x y z\n" + + +class TestScaling: + def test_handles_a_large_catalogue_without_quadratic_extraction(self): + # AUDIT 4.5: the original re-extracted six full arrays from the + # catalogue inside the per-candidate loop. This is a smoke test that a + # realistic base sample completes promptly. + rng = np.random.default_rng(0) + n = 4000 + cat = { + "id": np.arange(n), + "mvir": rng.uniform(7.1e11, 6.9e12, n), + "rvir": rng.uniform(100.0, 300.0, n), + "posX": rng.uniform(0.0, 100.0, n), + "posY": rng.uniform(0.0, 100.0, n), + "posZ": rng.uniform(0.0, 100.0, n), + } + out = candidates.select( + cat, RANGE, [ExclusionZone(distance=1.4, mass_multiple=0.5)], hubble=0.6711 + ) + assert 0 < len(out) <= n diff --git a/tests/test_writers_gadget.py b/tests/test_writers_gadget.py new file mode 100644 index 0000000..583ed1c --- /dev/null +++ b/tests/test_writers_gadget.py @@ -0,0 +1,246 @@ +import pytest + +from cme import cosmology +from cme.writers import gadget + + +def parse(text): + """Gadget's parser is whitespace-separated `key value`, `%` for comments.""" + params = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("%"): + continue + key, value = line.split(None, 1) + assert key not in params, f"duplicate parameter {key!r}" + params[key] = value + return params + + +PARAM = dict(cosmo=cosmology.PLANCK, boxsize=100.0, levelmax=11) + + +class TestSoftenings: + def test_halo_is_one_fortieth_of_the_grid_spacing(self): + soft = gadget.softenings(100.0, 11) + assert soft["Halo"] == pytest.approx(100.0 / 2**11 / 40) + + def test_each_type_doubles_the_previous(self): + soft = gadget.softenings(100.0, 11) + assert soft["Disk"] == pytest.approx(2 * soft["Halo"]) + assert soft["Bulge"] == pytest.approx(2 * soft["Disk"]) + assert soft["Stars"] == pytest.approx(2 * soft["Bulge"]) + assert soft["Bndry"] == pytest.approx(2 * soft["Stars"]) + + def test_gas_is_zero_for_dark_matter_only_runs(self): + assert gadget.softenings(100.0, 11)["Gas"] == 0.0 + + def test_finer_levelmax_gives_smaller_softening(self): + assert gadget.softenings(100.0, 12)["Halo"] < gadget.softenings(100.0, 11)["Halo"] + + def test_reproduces_the_hardcoded_defaults(self): + # The Gadget tab shipped these as literal defaults for a 100 Mpc/h box + # at levelmax 11; they are the reference for the scaling above. + soft = gadget.softenings(100.0, 11) + assert soft["Halo"] == pytest.approx(0.001220703, rel=1e-6) + assert soft["Disk"] == pytest.approx(0.002441406, rel=1e-6) + + +class TestPmgrid: + @pytest.mark.parametrize( + "levelmax,expected", + [(7, 256), (10, 256), (11, 256), (12, 512), (13, 512), (14, 1024), (15, 1024)], + ) + def test_thresholds(self, levelmax, expected): + assert gadget.pmgrid_for_levelmax(levelmax) == expected + + def test_accepts_strings(self): + # The Gadget tab's lmax is an Enum of strings. + assert gadget.pmgrid_for_levelmax("12") == 512 + + +class TestParamFile: + def test_has_no_duplicate_keys(self): + parse(gadget.param_file(**PARAM)) # asserts internally + + def test_hubble_param_is_dimensionless_h(self): + # The GUI stored H0 in a trait named HubbleParam and divided by 100 on + # the way out, so the displayed and written values disagreed. Gadget + # wants h. + params = parse(gadget.param_file(**PARAM)) + assert float(params["HubbleParam"]) == pytest.approx(0.6711) + + def test_cosmological_parameters_come_from_the_cosmology(self): + params = parse(gadget.param_file(**PARAM)) + assert float(params["Omega0"]) == pytest.approx(0.3175) + assert float(params["OmegaLambda"]) == pytest.approx(0.6825) + + def test_omega_baryon_is_independent_of_the_cosmology(self): + # Dark-matter-only runs zero this while keeping the rest of the set. + params = parse(gadget.param_file(**PARAM)) + assert float(params["OmegaBaryon"]) == 0.0 + params = parse(gadget.param_file(**PARAM, omega_baryon=0.049)) + assert float(params["OmegaBaryon"]) == pytest.approx(0.049) + + def test_softenings_are_derived_from_boxsize_and_levelmax(self): + params = parse(gadget.param_file(**PARAM)) + expected = gadget.softenings(100.0, 11) + assert float(params["SofteningHalo"]) == pytest.approx(expected["Halo"]) + assert float(params["SofteningHaloMaxPhys"]) == pytest.approx(expected["Halo"]) + + def test_subfind_parameters_only_present_when_requested(self): + without = parse(gadget.param_file(**PARAM)) + assert "ErrTolThetaSubfind" not in without + assert "DesLinkNgb" not in without + + with_sub = parse(gadget.param_file(**PARAM, include_subfind=True)) + assert float(with_sub["ErrTolThetaSubfind"]) == pytest.approx(0.7) + assert int(with_sub["DesLinkNgb"]) == 20 + + def test_contains_every_parameter_the_gui_exposed(self): + params = parse(gadget.param_file(**PARAM, include_subfind=True)) + required = { + "InitCondFile", + "OutputDir", + "EnergyFile", + "InfoFile", + "TimingsFile", + "CpuFile", + "RestartFile", + "SnapshotFileBase", + "OutputListFilename", + "TimebinFile", + "ICFormat", + "SnapFormat", + "TimeLimitCPU", + "CpuTimeBetRestartFile", + "ResubmitOn", + "ResubmitCommand", + "MaxMemSize", + "PartAllocFactor", + "BufferSize", + "TimeBegin", + "TimeMax", + "ComovingIntegrationOn", + "PeriodicBoundariesOn", + "CoolingOn", + "StarformationOn", + "Omega0", + "OmegaLambda", + "OmegaBaryon", + "HubbleParam", + "BoxSize", + "OutputListOn", + "TimeBetSnapshot", + "TimeOfFirstSnapshot", + "TimeBetStatistics", + "NumFilesPerSnapshot", + "NumFilesWrittenInParallel", + "TypeOfTimestepCriterion", + "ErrTolIntAccuracy", + "CourantFac", + "MaxRMSDisplacementFac", + "MaxSizeTimestep", + "MinSizeTimestep", + "InitGasTemp", + "MinGasTemp", + "TypeOfOpeningCriterion", + "ErrTolTheta", + "ErrTolForceAcc", + "TreeDomainUpdateFrequency", + "DesNumNgb", + "MaxNumNgbDeviation", + "UnitLength_in_cm", + "UnitMass_in_g", + "UnitVelocity_in_cm_per_s", + "GravityConstantInternal", + "MinGasHsmlFractional", + "ArtBulkViscConst", + "ErrTolThetaSubfind", + "DesLinkNgb", + } + for name in ("Gas", "Halo", "Disk", "Bulge", "Stars", "Bndry"): + required |= {f"Softening{name}", f"Softening{name}MaxPhys"} + assert required - set(params) == set() + + def test_explicit_softening_overrides_the_derived_one(self): + custom = dict.fromkeys(("Gas", "Halo", "Disk", "Bulge", "Stars", "Bndry"), 1.5) + params = parse(gadget.param_file(**PARAM, softening=custom)) + assert float(params["SofteningHalo"]) == 1.5 + + +class TestExpansionList: + def test_one_line_per_output(self): + text = gadget.expansion_list([(0.02, 1), (0.5, 0), (1.0, 1)]) + assert text.splitlines() == ["0.02 1", "0.5 0", "1.0 1"] + + def test_flag_is_an_integer(self): + assert gadget.expansion_list([(0.5, True)]).strip() == "0.5 1" + + def test_empty_list_gives_empty_file(self): + assert gadget.expansion_list([]) == "" + + +class TestConfigSh: + def enabled(self, text): + """The set of active compile-time tokens, with trailing comments stripped.""" + tokens = set() + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + tokens.add(line.split("#", 1)[0].strip()) + return tokens + + def test_every_commented_option_keeps_a_space_before_its_comment(self): + # `FOO=1# comment` is ambiguous to naive parsers of Config.sh. + for line in gadget.config_sh(include_subfind=True).splitlines(): + body, _, comment = line.partition("#") + if body.strip() and comment: + assert body.endswith(" "), line + + def test_starts_with_a_shebang(self): + assert gadget.config_sh().startswith("#!/bin/bash\n") + + def test_pmgrid_is_emitted_enabled(self): + assert "PMGRID=512" in self.enabled(gadget.config_sh(pmgrid=512)) + assert "PMGRID=1024" in self.enabled(gadget.config_sh(pmgrid=1024)) + + def test_options_always_on(self): + on = self.enabled(gadget.config_sh()) + for token in ( + "PERIODIC", + "UNEQUALSOFTENINGS", + "PEANOHILBERT", + "WALLCLOCK", + "MYSORT", + "DOUBLEPRECISION", + "DOUBLEPRECISION_FFTW", + "PLACEHIGHRESREGION=2", + ): + assert token in on + + def test_options_always_off(self): + on = self.enabled(gadget.config_sh()) + for token in ("COOLING", "SFR", "SINKS", "TWODIMS", "ONEDIM"): + assert token not in on + + def test_subfind_disabled_by_default(self): + on = self.enabled(gadget.config_sh()) + assert "FOF" not in on + assert "SUBFIND" not in on + + def test_subfind_enables_fof_and_subfind_together(self): + on = self.enabled(gadget.config_sh(include_subfind=True)) + assert "FOF" in on + assert "SUBFIND" in on + assert "FOF_PRIMARY_LINK_TYPES=2" in on + + def test_secondary_link_types_are_configurable(self): + text = gadget.config_sh(include_subfind=True, fof_secondary_link_types="4+8") + assert "FOF_SECONDARY_LINK_TYPES=4+8" in self.enabled(text) + + def test_disabled_options_stay_discoverable_as_comments(self): + # Gadget's Config.sh convention: "off" means present but commented. + assert "#COOLING" in gadget.config_sh() + assert "#SUBFIND" in gadget.config_sh() diff --git a/tests/test_writers_music.py b/tests/test_writers_music.py new file mode 100644 index 0000000..f5f127d --- /dev/null +++ b/tests/test_writers_music.py @@ -0,0 +1,203 @@ +import configparser + +import pytest + +from cme import cosmology +from cme.writers import music + + +def parse(text): + """MUSIC configs are INI, so the emitted text must survive a real parser.""" + parser = configparser.ConfigParser() + parser.read_string(text) + return parser + + +RESIM = dict( + cosmo=cosmology.PLANCK, + boxlength=100, + zstart=127, + levelmin=7, + levelmin_TF=9, + levelmax=11, + padding=7, + overlap=4, + region="ellipsoid", + region_point_file="/data/ics/lagr/H190897NRVIR4", + seed_level=9, + base_seed=34567, + halo_id=190897, + nrvir=4, +) + + +class TestOutputFormat: + def test_translates_gui_names_to_music_names(self): + assert music.output_format("gadget") == "gadget2" + assert music.output_format("music") == "generic" + + def test_passes_other_formats_through(self): + for name in ("enzo", "grafic2", "generic", "tipsy"): + assert music.output_format(name) == name + + +class TestRefinementSeeds: + def test_coarse_level_uses_the_base_seed(self): + seeds = dict(music.refinement_seeds(9, 11, 190897, 4, 34567)) + assert seeds[9] == 34567 + + def test_one_seed_per_level_inclusive_of_levelmax(self): + seeds = music.refinement_seeds(9, 12, 190897, 4, 34567) + assert [level for level, _ in seeds] == [9, 10, 11, 12] + + def test_is_deterministic_for_a_given_halo(self): + assert music.refinement_seeds(9, 11, 190897, 4, 34567) == ( + music.refinement_seeds(9, 11, 190897, 4, 34567) + ) + + def test_differs_between_halos_and_between_nrvir(self): + base = music.refinement_seeds(9, 11, 190897, 4, 34567) + assert base != music.refinement_seeds(9, 11, 190898, 4, 34567) + assert base != music.refinement_seeds(9, 11, 190897, 5, 34567) + + def test_reproduces_the_original_derivation(self): + # Pinned against the original inline arithmetic: delta starts at nrvir + # and is updated as delta += delta*2 + 1 before each level is emitted. + assert music.refinement_seeds(9, 11, 190897, 4, 34567) == [ + (9, 34567), + (10, 190937), + (11, 191018), + ] + + +class TestResimConf: + def test_is_valid_ini_with_the_expected_sections(self): + parser = parse(music.resim_conf(**RESIM)) + assert set(parser.sections()) == {"setup", "cosmology", "random", "output", "poisson"} + + def test_setup_values(self): + setup = parse(music.resim_conf(**RESIM))["setup"] + assert setup["boxlength"] == "100" + assert setup["zstart"] == "127" + assert setup["levelmin"] == "7" + assert setup["levelmax"] == "11" + assert setup["padding"] == "7" + assert setup["overlap"] == "4" + assert setup["region"] == "ellipsoid" + assert setup["region_point_file"] == "/data/ics/lagr/H190897NRVIR4" + + def test_booleans_are_spelled_yes_and_no(self): + setup = parse(music.resim_conf(**RESIM, baryons=True, use_2LPT=False))["setup"] + assert setup["baryons"] == "yes" + assert setup["use_2lpt"] == "no" + assert setup["periodic_tf"] == "yes" + + def test_ellipsoid_region_omits_ref_center_and_extent(self): + # An ellipsoid is defined by the point file alone. + setup = parse(music.resim_conf(**RESIM))["setup"] + assert "ref_center" not in setup + assert "ref_extent" not in setup + + def test_box_region_includes_ref_center_and_extent(self): + kwargs = dict( + RESIM, region="box", ref_center=(0.51, 0.52, 0.53), ref_extent=(0.21, 0.22, 0.23) + ) + setup = parse(music.resim_conf(**kwargs))["setup"] + assert setup["ref_center"] == "0.51,0.52,0.53" + assert setup["ref_extent"] == "0.21,0.22,0.23" + + def test_cosmology_section_uses_H0_not_h(self): + # MUSIC wants H0 in km/s/Mpc. + cosmo = parse(music.resim_conf(**RESIM))["cosmology"] + assert float(cosmo["h0"]) == pytest.approx(67.11) + assert float(cosmo["omega_m"]) == pytest.approx(0.3175) + assert float(cosmo["sigma_8"]) == pytest.approx(0.8344) + + def test_random_section_has_one_seed_per_refinement_level(self): + random = parse(music.resim_conf(**RESIM))["random"] + assert sorted(random) == ["seed[10]", "seed[11]", "seed[9]"] + + def test_output_format_is_translated(self): + output = parse(music.resim_conf(**RESIM, out_format="gadget"))["output"] + assert output["format"] == "gadget2" + + def test_golden(self): + assert music.resim_conf(**RESIM) == GOLDEN_RESIM + + +class TestParentConf: + PARENT = dict( + cosmo=cosmology.PLANCK, + boxlength=100, + zstart=127, + level=9, + levelmin_TF=9, + padding=8, + overlap=4, + ref_center=(0.5, 0.5, 0.5), + ref_extent=(0.2, 0.2, 0.2), + seed=34567, + ) + + def test_is_unrefined_so_levelmin_equals_levelmax(self): + setup = parse(music.parent_conf(**self.PARENT))["setup"] + assert setup["levelmin"] == setup["levelmax"] == "9" + + def test_has_exactly_one_seed(self): + random = parse(music.parent_conf(**self.PARENT))["random"] + assert list(random) == ["seed[9]"] + assert random["seed[9]"] == "34567" + + def test_always_writes_ref_center_and_extent(self): + setup = parse(music.parent_conf(**self.PARENT))["setup"] + assert setup["ref_center"] == "0.5, 0.5, 0.5" + + def test_has_no_region_point_file(self): + assert "region_point_file" not in parse(music.parent_conf(**self.PARENT))["setup"] + + +GOLDEN_RESIM = """\ +[setup] +boxlength = 100 +zstart = 127 +levelmin = 7 +levelmin_TF = 9 +levelmax = 11 +padding = 7 +overlap = 4 +region = ellipsoid +region_point_file = /data/ics/lagr/H190897NRVIR4 +align_top = no +baryons = no +use_2LPT = no +use_2LLA = no +periodic_TF = yes + +[cosmology] +Omega_m = 0.3175 +Omega_L = 0.6825 +Omega_b = 0.049 +H0 = 67.11 +sigma_8 = 0.8344 +nspec = 0.9624 +transfer = eisenstein + +[random] +seed[9] = 34567 +seed[10] = 190937 +seed[11] = 191018 + +[output] +format = gadget2 +filename = ./ics +gadget_num_files = 4 + +[poisson] +fft_fine = yes +accuracy = 1e-05 +pre_smooth = 3 +post_smooth = 3 +smoother = gs +laplace_order = 6 +grad_order = 6 +""" diff --git a/tests/test_writers_submit.py b/tests/test_writers_submit.py new file mode 100644 index 0000000..f2f345b --- /dev/null +++ b/tests/test_writers_submit.py @@ -0,0 +1,145 @@ +import pytest + +from cme.writers import submit + + +class TestMpirunCommand: + def test_plain_run(self): + assert submit.mpirun_command(ranks=64) == ( + "mpirun -np 64 ./P-Gadget3 ./param.txt 1>OUTPUT 2>ERROR" + ) + + def test_subfind_run_uses_the_sub_binary_param_and_logs(self): + assert submit.mpirun_command(ranks=64, include_subfind=True) == ( + "mpirun -np 64 ./P-Gadget3_sub ./param_sub.txt 3 63 1>OUTPUTsub 2>ERRORsub" + ) + + def test_subfind_snapshot_is_configurable(self): + cmd = submit.mpirun_command(ranks=8, include_subfind=True, subfind_snapshot=127) + assert " 3 127 " in cmd + + def test_ranks_accept_strings_without_concatenating(self): + # AUDIT 2.1.1: the SLURM branch concatenated an int trait with a string. + # Coercing here means neither a str nor an int input can break it. + assert "-np 512" in submit.mpirun_command(ranks="512") + assert "-np 512" in submit.mpirun_command(ranks=512) + + def test_log_filenames_are_configurable(self): + cmd = submit.mpirun_command(ranks=8, out="LOG", err="ERR") + assert "1>LOG 2>ERR" in cmd + + +class TestSlurmScript: + KWARGS = dict( + job_name="H19EP7L11N4", + work_dir="/data/halos/H190897/H190897_BE_Z127_P7_LN7_LX11_O4_NV4", + execute="mpirun -np 64 ./P-Gadget3 ./param.txt 1>OUTPUT 2>ERROR", + cores=64, + minutes=5000, + memory_mb=3500, + queue="itc_cluster", + email="someone@example.org", + ) + + def test_integer_fields_do_not_raise(self): + # AUDIT 2.1.1: this is the regression test for the TypeError that made + # SLURM submission impossible -- `'#SBATCH -n ' + self.SLURMcores`. + text = submit.slurm_script(**self.KWARGS) + assert "#SBATCH -n 64" in text + assert "#SBATCH -t 5000" in text + assert "#SBATCH --mem-per-cpu=3500" in text + + def test_accepts_string_valued_traits_too(self): + text = submit.slurm_script( + **dict(self.KWARGS, cores="64", minutes="5000", memory_mb="3500") + ) + assert "#SBATCH -n 64" in text + + def test_starts_with_bash_shebang(self): + assert submit.slurm_script(**self.KWARGS).startswith("#!/bin/bash\n") + + def test_changes_into_the_run_directory_before_executing(self): + lines = [ln for ln in submit.slurm_script(**self.KWARGS).splitlines() if ln.strip()] + assert lines.index(f"cd {self.KWARGS['work_dir']}") < lines.index(self.KWARGS["execute"]) + + def test_mail_directives_omitted_without_an_email(self): + text = submit.slurm_script(**dict(self.KWARGS, email=None)) + assert "--mail" not in text + + def test_queue_and_job_name(self): + text = submit.slurm_script(**self.KWARGS) + assert "#SBATCH -p itc_cluster" in text + assert "#SBATCH -J H19EP7L11N4" in text + + +class TestPbsScript: + KWARGS = dict( + job_name="H19EP7L11N4", + work_dir="/data/halos/H190897/run", + execute="mpirun -np 24 ./P-Gadget3 ./param.txt 1>OUTPUT 2>ERROR", + cores_per_node=8, + nodes=3, + queue="default", + email="someone@example.org", + ) + + def test_resource_line_multiplies_out_as_nodes_and_ppn(self): + assert "#PBS -l nodes=3:ppn=8" in submit.pbs_script(**self.KWARGS) + + def test_uses_csh(self): + assert submit.pbs_script(**self.KWARGS).startswith("#!/bin/csh\n") + + def test_log_paths_are_absolute(self): + text = submit.pbs_script(**self.KWARGS) + assert "#PBS -o /data/halos/H190897/run/H19EP7L11N4.out" in text + assert "#PBS -e /data/halos/H190897/run/H19EP7L11N4.err" in text + + def test_extra_flags_and_lines_are_included(self): + text = submit.pbs_script( + **dict( + self.KWARGS, + extra_flags="#PBS -m ae", + extra_lines="source /opt/torque/etc/openmpi-setup.sh", + ) + ) + assert "#PBS -m ae" in text + assert "source /opt/torque/etc/openmpi-setup.sh" in text + + def test_extra_fields_omitted_when_blank(self): + text = submit.pbs_script(**self.KWARGS) + assert "\n\n\n" not in text + + +class TestDispatch: + def test_selects_by_name_case_insensitively(self): + common = dict(job_name="j", work_dir="/w", execute="run") + slurm = submit.script("slurm", **common, cores=8, minutes=10, memory_mb=100, queue="q") + assert slurm.startswith("#!/bin/bash") + pbs = submit.script("PBS", **common, cores_per_node=8, nodes=1, queue="q") + assert pbs.startswith("#!/bin/csh") + + def test_rejects_unknown_scheduler(self): + with pytest.raises(ValueError, match="unknown scheduler"): + submit.script("lsf", job_name="j", work_dir="/w", execute="run") + + +class TestSubfindConsistencyAcrossSchedulers: + """AUDIT 2.2.5 / 2.2.6. + + Both schedulers must derive their rank count from their own core setting, + and both must switch to the `_sub` binary when SUBFIND is enabled. The GUI + built the SLURM command from the PBS core count, and left the SLURM command + on the plain binary because both branches of `_SLURMcores_changed` were + identical. + """ + + def test_each_scheduler_uses_its_own_rank_count(self): + slurm_ranks, pbs_ranks = 512, 24 + assert f"-np {slurm_ranks}" in submit.mpirun_command(ranks=slurm_ranks) + assert f"-np {pbs_ranks}" in submit.mpirun_command(ranks=pbs_ranks) + + @pytest.mark.parametrize("ranks", [24, 512]) + def test_subfind_switches_binary_regardless_of_rank_count(self, ranks): + cmd = submit.mpirun_command(ranks=ranks, include_subfind=True) + assert "P-Gadget3_sub" in cmd + assert "param_sub.txt" in cmd