Skip to content

test: hermetic unit-test layer + batches 1-10 (utils, datalayer, gaussian, physics, risk, meteorology, GIS, experiment, openFoam/LSM/mlDL/WRF, evaporation/deposition) - #1067

Open
ilayfalach wants to merge 53 commits into
masterfrom
tests/batch10-pure-physics
Open

test: hermetic unit-test layer + batches 1-10 (utils, datalayer, gaussian, physics, risk, meteorology, GIS, experiment, openFoam/LSM/mlDL/WRF, evaporation/deposition)#1067
ilayfalach wants to merge 53 commits into
masterfrom
tests/batch10-pure-physics

Conversation

@ilayfalach

Copy link
Copy Markdown
Collaborator

Summary

First installment of the hermetic unit-test layer described in #1061, plus ten batches of new unit tests against hera/. Everything here is additive test coverage under hera/tests/unit/, backed by a mongomock reroute (no real MongoDB, no network) and a stub layer for CI-unavailable packages (PyFoam/paraview/FreeCAD/hermes/argos/evtk).

  • Phase 0 infra: mongomock seam, stub layer, conftest.py isolation guards (no network, no real .hera home writes, DB reset between tests), CI wiring (ci.yml runs the unit marker before the integration suite), and a monotonically-increasing coverage_floor.txt gate.
  • Batches 1-10: hera/utils, toolkit.py + datalayer, simulations/gaussian, evaporation/deposition/hydrodynamics/windProfile/simulations/utils, riskassessment + presentation, measurements/meteorology, measurements/GIS, measurements/experiment, openFoam/LSM/mlDL/WRF, and the evaporation/deposition code unblocked by the batch-10 import fix.
  • 51 real defects found and documented (docs/superpowers/findings/2026-08-24-test-expansion-findings.md), each pinned with @pytest.mark.xfail(strict=True) rather than fixed -- so a future fix is forced to remove the marker. A handful of suspected bugs that turned out to be correct behavior are recorded too, to save the next person from re-investigating them.
  • One production fix (8eaa510a, batch 10): unblocked the integration suite by removing a circular import in autocache.py, an operator-precedence bug in the same file, six wrong-level imports, an import-time side effect in hill2stl.py, and a test-detection gap in test_no_invalid_escapes.py. Full rationale in the commit message.

Note on this branch's history

This branch (and each tests/batchN-* branch after it) is a linear stack -- batch2 is built on batch1, batch3 on batch2, and so on. This PR is therefore the right unit to merge as a whole; opening separate PRs per batch against master would show overlapping, duplicated diffs. The next chunk (batches 11-19) will follow as its own PR once this one is in, and so on through batch 28.

Conflict resolution against current master

This branch's fork point predates ~21 commits that have since landed on master, including a separate dead-code-cleanup effort. Two collisions were resolved in the merge commit (2a918afc):

  • hera/datalayer/autocache.py: this branch's own fix already supersedes master's (same root cause, plus an additional tuple-return bug) -- kept ours.
  • hera/measurements/GIS/raster/hill2stl.py: deleted on master as dead code; accepted the deletion and dropped the now-orphaned TestImportSideEffects test class (its only remaining reference), and removed the now-deleted hera.simulations.LSM.hermesWorkflowToolkit from a stub-layer import smoke test's module list for the same reason.

Test plan

  • hera/tests/unit -m unit -q: 1076 passed, 3 skipped, 80 xfailed, 0 failed (verified after merging current master and resolving the two collisions above)
  • No production code changed beyond the one test-unblocking fix described above
  • Integration suite (hera/tests/, excluding unit/) -- left to CI; not runnable locally in this environment (no local hermes package)

🤖 Generated with Claude Code

Ilay Falach and others added 30 commits August 24, 2026 11:06
Measured the current state (1,333 public callables, 407 test functions, no
coverage measurement) and probed the code for what actually blocks unit
testing.  Key finding: hera.datalayer.document.connectToDatabase is a single
seam through which the whole Project/toolkit stack runs on mongomock with no
production code changes -- verified end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
requirements.txt is deliberately left untouched so production installs do
not change.  coverage_floor.txt holds a placeholder until the baseline is
measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extracted as an importable module from the proven inline block in
test_toolkit_coverage.py, which is left untouched.  Tests fail until
conftest.py wires install() in (next task).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Substitutes hera.datalayer.document.connectToDatabase so the real datalayer
runs against an in-memory store: no production code is modified.  Bootstrap
order is load-bearing because hera/datalayer/__init__.py builds collection
singletons at import time and raises KeyError without a pyhera config.

Also rebinds the module-level Measurements/Simulations/Cache/All singletons,
which are created before the seam exists and are used directly by
abstractcalculator.py:172,190,221.

Overrides the parent conftest's _no_trace_guard: a parent autouse session
fixture applies to subdirectories, and that one purges projects in the real
MongoDB.  Leaving it in place cost 62.95s per run against a stopped MongoDB
(pymongo's 30s server-selection timeout, twice); the unit layer now runs in
under a second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Network access raises immediately instead of waiting out pymongo's 30s
server-selection timeout.  Also pins matplotlib to Agg and asserts no test
writes a .hera directory.

The seam tests still pass with the socket guard active, which confirms
mongomock opens no socket at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test order must never matter; verified by running the write/read pair in
both orders.  Factories give deterministic control over the edge cases the
S3 data set does not contain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four fixtures skip when TEST_HERA is absent or partial, so a failed S3
download makes CI green with dozens of silent skips.  The gate is off by
default: local runs keep skipping, which is the historical behaviour and
stays the documented one.  CI arms it with HERA_REQUIRE_TEST_DATA=1.

Verified both directions end to end: with TEST_HERA=/nonexistent the suite
skips and exits 0; with the gate armed (env var or --require-data) it fails
and exits 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unit conftest's bootstrap is process-wide: it moves HOME, replaces
connectToDatabase and rebinds the datalayer singletons.  Collecting it
alongside the integration tests made three dynamic_loading tests fail with
'Authentication failed' -- their subprocesses inherited the moved HOME and
its placeholder credentials.  All three disappear once the directory is not
collected.

-m 'not unit' cannot prevent this because marker filtering happens after
collection, so the integration invocations now pass
--ignore=hera/tests/unit, and the unit conftest raises a UsageError naming
the correct commands if the two layers are ever mixed again.  A silent wrong
result becomes an immediate, explanatory failure.

Also adds the make targets (test-unit, coverage-unit, coverage) and omits
from coverage the six files under hera/ that are not valid Python: they
break 'coverage report' outright and would otherwise pad the denominator
with lines no test can reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unit job has no MongoDB service and no S3 test data by design: if it
needs either, the seam is broken.  A logic failure now surfaces in under a
minute instead of after service setup and an S3 download.

The integration job arms HERA_REQUIRE_TEST_DATA and enforces the coverage
floor on the combined measurement of both layers.

Coverage plumbing detail that is easy to get wrong: pytest-cov already folds
its parallel fragments into COVERAGE_FILE at the end of a run, so a bare
'coverage combine' afterwards has nothing to merge and exits 1, failing the
step.  Each layer therefore writes a distinctly named file (.coverage.unit,
.coverage.integration) and combine runs only in the job that holds both
halves -- which also avoids the two jobs colliding on the artifact name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three separate measurements rather than one, because it matters which is
which:

    unit layer only ......... 7%   (18446 stmts, 16920 missed)
    integration only ....... 22%   (18568 stmts, 13878 missed)
    combined ............... 22%   (18568 stmts, 13871 missed)

Combining recovered seven statements.  The 26 infrastructure tests are very
nearly a subset of what the integration suite already covers, which is the
honest reading: their value is that they run in 3.5 seconds with no MongoDB,
no S3 data and no network -- not that they add coverage.  Coverage comes from
batches 1-9.

The floor is 20, not the measured 22: CI runs Python 3.11 with the pinned
requirements, so its number will differ, and the gate should fail on
regressions rather than on environment drift.  coverage_floor.txt now carries
that reasoning inline, and both readers take the first numeric line.

Note for whoever runs CI next: the floor step sits after the integration
pytest step, which currently fails on two pre-existing TestAutoCache errors
(a circular import in hera/datalayer/autocache.py and a TypeError).  Both
reproduce on a clean origin/master worktree under pytest 7.2.0 and 9.0.3, so
the gate will not start enforcing until those are fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--ignore=hera/tests/unit is what keeps the new hermetic layer out of the
integration process; -m 'not unit' on top of it was wrong.  147 pre-existing
tests inside the integration files carry @pytest.mark.unit (most of
test_toolkit_coverage.py), and excluding the marker meant they ran in neither
job -- the unit job only collects hera/tests/unit.

Verified on one tree: 1444 collected without --ignore, 1418 with it, 26 in
the unit directory alone.  1418 + 26 = 1444, so --ignore removes exactly the
new layer and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scopes the 14 importable modules (912 statements, 23% covered) and defers
five with reasons -- rag/* and SALibUtils cannot even be imported, because
httpx, chromadb, sentence_transformers, llama_index and SALib are absent and
none of them appear in requirements.txt.

Records eight defects verified by running the code before any test was
written, each to be pinned with xfail(strict=True) so that fixing one makes
the test demand its own removal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assertions derive from the conventions CLAUDE.md states (meteorological
0=North clockwise, mathematical 0=East counter-clockwise) and from the
docstrings in query.py, not from what the code returns.

angle.py reported 100% line coverage before this commit while nothing tested
its behaviour -- the three lambdas are executed at import.  A useful
demonstration that coverage alone measures the wrong thing.

Two defects pinned with xfail(strict=True): toAzimuthAngle does not normalise
input outside one cycle, and dictToMongoQuery indexes list-valued mongoengine
operator suffixes so __in silently matches nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filter: all ten documented prepositions against the rows they keep, the
inplace/copy contract in both directions, and the asymmetric outsideInterval
boundary (lower inclusive, upper exclusive) which is easy to get backwards.

_LazyModule: that construction imports nothing, that failure is deferred to
first access too, and that the loaded proxy is transparent.  A proxy whose
import is not actually deferred would pass a naive test.

calcDist2d: a three-point fixture small enough to verify by hand, so the
assertions state what the normalisations mean rather than snapshotting them --
including that y_normalized leaves an empty row at zero instead of NaN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
compareDataframeConfigurations: the three documented input shapes are checked
to agree with each other, and absence of a parameter in one set is asserted to
count as a difference -- that branch is easy to lose in a refactor because it
looks redundant next to the value-differs branch.

prepareSlurmScriptExecution: the generated directives are checked against
Slurm's syntax, not against current output.  That is what surfaces B7, where
the memory directive is written '-mem=' instead of '--mem=' and sbatch would
reject the file.  Also pins that invalid argument combinations write no file
at all rather than a half-formed one.

Two defects pinned with xfail(strict=True): B4 and B7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
latex.py had no coverage at all.  Testing convert() against the rules stated
in its own comments surfaced B9: the math-mode branch compares a single
character to the string 'DOLLAR SIGN', so it can never fire, and the class
docstring's own example -- $4\frac{m}{s}$ -- comes out as
$$4\$\L{frac}{\L{m}}{\L{s}}$.  Two more in the parser: items[0] is
always an empty bibItem, and an empty file raises IndexError.

zipUtils: the arcname conventions differ between the file branch (basename)
and the directory branch (relative to the parent, so the directory name is
kept) -- both pinned, since a refactor that unified them would break callers
silently.  Also pins that JSON selection is a substring match, not an
extension check.

Five defects pinned with xfail(strict=True): B5, B6, B9, B10, B11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Units are checked against their definitions -- a dunam is 1000 m2, mmH2O is
atm/10197.162129779, 0 degC is 273.15 K -- which is what surfaced B12: tounit
constructs Quantity(x, unit) from the bare pint import instead of
ureg.Quantity, so its result lives in pint's default application registry.
Verified consequences: the value cannot be combined with any hera quantity
('Cannot operate with Quantity of different registries'), and
tounit(1, 'dunam') raises UndefinedUnitError -- the one custom unit hera's
registry exists to provide is exactly the one this public helper cannot make.
mmH2O only appears to work because pint ships a millimeter_H2O of its own.

Contours are checked geometrically: a level-1 contour of x^2+y^2 is the unit
circle, so the resulting polygon must enclose an area of pi, and switching the
input unit to kilometres must scale that area by exactly 1e6.

Three defects pinned with xfail(strict=True): B8 and B12 (twice).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jsonutils is the largest module in hera/utils (224 statements, 21% covered).
The tests are written as round trips, because that is the property callers
depend on: a config with units has to come back out of JSON unchanged.

Testing the documented meaning of returnStandardize surfaced B13.  It is
described as 'return the units in MKS', but the decoder reads the 'units'
field, which only holds base units when the ENCODER was given
standardize=True.  Asking the decoder for MKS on a document encoded without
it returns the original units and reports nothing.

Also pins that setJSONPath copies by default (a variation sweep that mutated
its base would corrupt every later variation) and that a path matching
nothing raises rather than being ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hera/utils/__init__.py exists so that `import hera` does not pull in pint,
pandas, shapely and geopandas.  Its __all__ is the package's contract, so the
test resolves every entry in one assertion -- a stale name is a broken import
for somebody.  Also pins that a resolved name is cached into globals(), which
is what makes the second lookup free, and that underscore names short-circuit
before any submodule import is attempted.

Writing these found B14: with_logger is annotated "-> (str, dict)" but returns
a 3-tuple (name, config, 'loggers').  Unpacking it the way the annotation says
raises ValueError -- which is exactly how the first draft of this test failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Combined measurement (unit + integration, Python 3.12 / pytest 9.0.3):

    whole package .......... 22% -> 28%
    hera/utils ............. 23% -> 43%
    hera/utils in scope .... 23% -> 74%   (912 of 2397 statements)

The in-scope figure is the meaningful one for this batch.  The remaining 1485
statements in hera/utils are rag/*, data/CLI.py, data/toolkit*, SALibUtils and
freeCAD, deferred with reasons recorded in the batch 1 plan -- rag/* and
SALibUtils cannot be imported at all, because httpx, chromadb,
sentence_transformers, llama_index and SALib are absent and none of them are
declared in requirements.txt.

Floor keeps two points of headroom below the measured value: CI runs Python
3.11 with the pinned requirements, so the gate should fail on regressions
rather than on environment drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sixteen defects were scattered across commit messages and xfail reasons,
which is too fragile a home for material that becomes a single issue at the
end of the effort.  Each entry records the file, the test that pins it, and
the verification that was actually run -- none of them come from reading code
alone.

Notable: P6 (two TestAutoCache failures) is verified identical on a clean
origin/master worktree under both pytest 7.2.0 and 9.0.3, so it predates this
work and currently keeps the CI coverage gate from ever executing.  B12 and
B16 are the two worth acting on first: a public helper that cannot produce
hera's own custom unit, and 473 statements that cannot be imported at all
because five dependencies are undeclared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hera/toolkit.py was the largest single gap in this area at 30% coverage.
These run the real datalayer -- query construction, desc filtering, version
resolution, document tagging -- against the in-memory store, so they exercise
hera rather than a mock.

Version resolution is checked componentwise, including that [0,0,10] beats
[0,0,9] (string ordering would not) and that a tuple and a list version are
interchangeable even though dictToMongoQuery sends them through two different
query shapes.  Also pins that the getter does not write to the database: its
docstring still promises to persist the latest version as the default, but the
code removed that with a comment calling it a hidden side effect.  The code is
right and the docstring is stale.

Two defects pinned:

  B18: getDataSourceList is documented as returning "data source names" but
       yields one entry per document, so a source with three versions appears
       three times.  getDataSourceMap is the one documented as covering
       versions.

  B19: the document-adding overrides call desc.setdefault on the CALLER's
       dict.  The verified consequence is cross-toolkit misattribution -- a
       metadata dict reused across two toolkits keeps the first toolkit's tag,
       so the second toolkit's document is stored under the wrong name, is
       invisible to its own queries, and appears in the other toolkit's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The registry decides which storage format each Python object gets, so it
decides whether hera follows its own policy in CLAUDE.md -- tabular data as
parquet, never CSV.  All eighteen declared format constants are checked to
resolve to a handler exposing both saveData and getData; a constant with no
handler is a trap that only fails when someone tries to save.

Round trips for string, JSON_dict, parquet, numpy_array and pickle write to
tmp_path, so none of them touch the test data set.

B20: typeDatatypeMap is keyed on third-party INTERNAL module paths, such as
'pandas.core.frame.DataFrame'.  pandas 3 reports 'pandas.DataFrame', which the
map does not know, so every DataFrame silently degrades to pickle instead of
parquet -- no error, worse interoperability, and against the stated policy.
requirements.txt pins pandas 2.2.3, where the key still matches, so CI is
unaffected and only a drifted environment shows it.  The two tests therefore
carry an xfail condition computed at collection time from the installed
pandas, so the expectation is correct in both environments rather than flaky
in one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Counter semantics are documented but surprising, so they get pinned
explicitly: the first getCounterAndAdd call DEFINES the counter at 0 and adds
nothing, and only later calls increment.  N calls therefore leave the counter
at N-1.  The first draft of these tests assumed post-increment and failed --
the code was right and the assumption was wrong, which is exactly the case
where a test earns its keep.

Also pins that addDocumentFromDict drops an incoming projectName, so an
imported document joins the project doing the importing rather than the one it
came from, and that the three collections stay separate.

Note on an earlier suspicion: setConfig's long "default project" error looked
like it ended in a bare raise, which would have produced an opaque
RuntimeError instead of the message.  It does not -- project.py:247 is
raise ValueError(err).  Verified, no defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three new defects (B18-B20), plus a section for suspicions that were checked
and did NOT hold, so nobody spends time re-investigating them: setConfig does
raise its long error properly, every format constant has a handler, and the
counter semantics are documented and correct -- my test assumption was the
thing that was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
toolkit.py rises from 36% to 47% under the unit layer alone; the remaining gap
is the JSON import helpers.  CLAUDE.md forbids constructing toolkits directly,
so what this registry returns for a given constant is a public contract -- the
save-mode constants get their own test because those strings are persisted,
which makes changing one a data migration rather than a rename.

Three defects found, and together they mean the default-repository feature has
never worked:

  B21: _get_data_toolkit declares a projectName parameter and ignores it,
       returning dataToolkit(), which always operates on the default project.
       So a "per project" default is actually global.

  B22: because the write therefore lands on defaultProject, which Project
       guards as read-only (project.py:757), setDefaultRepository always
       raises RuntimeError.  registerToolkit sets
       _allowWritingToDefaultProject before writing (toolkit.py:1271);
       setDefaultRepository does not.

  B23: its data-format lookup tries datatypes.JSON, .json and .TEXT, none of
       which exist -- the constants are JSON_DICT, JSON_PANDAS and
       JSON_GEOPANDAS.  Dead code rather than a wrong result, since the payload
       lives in desc; the test fails if one of those names is ever added, so
       the lookup gets revisited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    combined ............... 28% -> 28%   (12937 -> 12850 missed)
    toolkit.py + datalayer . 61% -> 68%
    hera/toolkit.py ........ 47% from the unit layer alone (was 30%)

The floor does NOT move.  87 covered statements is a real gain inside the
group but a 0.4-point gain across the package, because the datalayer was
already well covered by the integration suite -- unlike hera/utils, which
batch 1 took from 23% to 74% in scope.  Raising the floor on that would leave
no headroom for the CI-versus-local drift the floor exists to tolerate.

What batch 2 delivered instead: behaviour that previously needed a live
MongoDB now runs hermetically in under two seconds, and six defects surfaced --
including B21/B22, which together mean the default-repository feature has
never worked in any environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed table

Every sigma assertion evaluates the Briggs (1973) open-country formula
independently and compares -- 36 direct comparisons over six stability classes
and six distances.  That distinction matters: an assertion copied from the
implementation's own output would pass even with a wrong coefficient table, so
the table is also checked entry by entry against the published values.

The physics comes out right.  sigma_y and sigma_z match to 1e-9 relative
across the whole grid, both grow monotonically with distance, and both order
correctly from A (most unstable, widest plume) to F.  The virtual-source
construction is self-consistent too: with sigma0 = 10 m the spread at x = 0 is
exactly 10 m, which is the property that makes the whole shifted-origin trick
valid.

B24: getSigma does not validate the distance.  x = -100 m under stability D
returns sigma_y = -8.04 m -- a negative standard deviation -- and a large
negative x returns NaN with a numpy RuntimeWarning, because (1 + b x) goes
negative under a fractional power.  Either input should be refused; silently
returning a negative sigma or NaN lets a bad distance propagate into a
concentration field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each quantity is checked against a value that can be looked up independently:
sea-level pressure is 1.0000 atm and falls to exactly 1/e at 1/1.186e-4 m; the
lapse rate is 6.5 K/km and linear; density at 20 C is 1.2046 kg/m3 against a
textbook 1.204; viscosity lands within 2.4% of 1.81e-5 Pa s and rises with
temperature as a gas must; the log profile reproduces u_ref exactly at the
reference height and vanishes at z0; and the hot-spot exponents match the
published 0.07 to 0.55 across stability classes.

Two behaviours are pinned as deliberate rather than accidental: getTKE ignores
its height argument (documented as neutral-only), and the wind profile clamps
height into [0, 300] m, which the docstring does not mention -- a caller asking
for 1 km silently gets the 300 m value.

B25: five docstrings under gaussian/ carry the r prefix on the CLOSING triple
quotes, so the LaTeX backslashes are invalid escapes and every import emits
SyntaxWarning.

B26 is the more interesting one, and it is about a test rather than the code.
test_no_invalid_escapes.py compiles each file with SyntaxWarning escalated to
an error -- but Python surfaces an escalated SyntaxWarning as SyntaxError, and
that test's handler for genuinely-unparseable legacy files catches SyntaxError
and calls pytest.skip.  So the failure it exists to detect can only ever become
a skip, and its message claims "file is not importable" about a module that
imports fine.  Two passing tests here demonstrate the mechanism.  Not fixed:
it is an existing working test and the standing instruction is to report.

One assumption of mine was wrong and the code was right: I expected rougher
terrain to slow the wind aloft.  With u10 pinned it does the opposite, because
ln(z/z0)/ln(10/z0) grows with z0 above the reference height.  The test now
states that with the numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mass conservation is the property worth holding onto, so the tests recover
sigma and total mass from the produced field rather than snapshotting an array:
a normalised puff integrates to Q to within 1e-6, scales linearly with Q, and
the recovered alongwind sigma matches the input across three widths.

Four defects, all verified numerically:

  B27: GaussianToMesh.__init__ sets sigmaYName to "sigmaXCorrected" -- the X
       column.  An input sigmaYCorrected of 50 yields a recovered sigma_y of
       5.000: the crosswind data is ignored and the plume is forced isotropic.
       For a dispersion model where sigma_x and sigma_y differ by design that
       is a physics error, not a rounding one.  A passing test shows the kernel
       itself is fine -- point sigmaYName at its own column and anisotropy
       works -- so only the constructor default is at fault.

  B28: _defineCoordinates takes a LABEL from .index[0] and feeds it to .iloc,
       which wants a POSITION.  Any frame that came out of a groupby, filter or
       concat raises IndexError.

  B29: GaussianIntegrationToMesh narrows its parent's input.  The base accepts
       a plain float or a pint quantity for Q; the subclass accepts only pint,
       because it calls unumToPint on the value itself.  Code written against
       the base breaks on exactly the substitution the subclass docstring
       invites by calling itself "more accurate".

  B30: gaussianToolkit.getMeteorologyFromU10 defaults to powerLaw while
       MeteorologyFactory.getMeteorologyFromU10 defaults to log, so the same
       call through two entry points gives two different wind profiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ilay Falach and others added 19 commits August 24, 2026 17:06
    combined ............... 28% -> 30%   (12850 -> 12513 missed)
    gaussian ............... 33%
    gaussian in scope ...... 66%          (470 of 1061 statements)
      Sigma.py ............ 100%
      Meteorology.py ....... 95%
      MeshUtils.py ......... 88%

337 covered statements, which is enough to move the package figure -- unlike
batch 2, where the datalayer was already covered by the integration suite.  The
gain here is close to the 311 statements the unit layer covers inside gaussian,
which suggests the integration suite contributed little there; I could not
measure that separately, because coverage combine consumed the integration data
file, so it is stated as an inference rather than a number.

Deferred with reasons: gasCloud.py (291), FallingNonEvaporatingDroplets.py
(217) and DropletCloud.py (83).  The gas-cloud solver and the falling-droplet
physics build multi-dimensional xarray fields and need whole meteorology and
source objects; they deserve a batch rather than the tail of this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rpolation

Half of this batch's nominal scope could not be tested at all, and finding out
why was the most valuable part of it.

B31: evaporation and deposition are unimportable.  All three of their modules
     do `from ..utils import tonumber, tounit`, which resolves to
     hera.simulations.utils -- an empty package.  The names are in hera.utils,
     three levels up, and a sibling in the same tree
     (gaussian/DropletCloud.py) already writes it with three dots.  Two whole
     physics packages are dead in any installation; the fix is one dot in three
     files.

B32: calculateR mixes normalisations.  Its numerator divides by N while its
     denominator uses pandas' .std(), which divides by N-1, so every
     correlation comes back short by exactly (N-1)/N.  Verified across five
     sample sizes: a perfect model scores 0.667 at N=3, 0.8 at N=5, 0.98 at
     N=50.  It can never reach 1.

B33: skin_friction contains `numpy.log()` with no argument, in BOTH channelFlow
     and couetteFlow.  Neither method can ever have run.

B34: ReynoldsUm returns 80000/meter.  The height property strips units with
     m_as(m) while the viscosity keeps them, so a dimensionless group comes out
     with dimensions.  Re_tau in the same class gets it right by using
     _channelHeight directly.

B35: ReynoldsUm never reads the Um it is documented and named for.

What does work is checked against its definitions: the Chang and Hanna metrics
all take their defining values for a perfect model, the technical roughness is
3.5 Ra per Schlichting, C+ hits 5 and 8 at its two limits and follows the
documented log law between them, and the interpolant is a genuine convex
combination -- exact at stations, bracketed between them, monotonic along the
line joining two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    combined ............... 30% -> 31%
    targeted files ......... 70%          (199 statements)
      errorCalculation.py . 100%
      nearWallFlow.py ...... 77%
      interpolations.py .... 53%

The batch's nominal scope was about 91 public callables; 312 statements of it
cannot be covered at all, because evaporation and deposition are unimportable
(B31).  That is a defect, not a testing shortfall, so the floor reflects what
was actually reachable.

A further 425 statements -- canopyWindProfile, coordinateHandler and
windProfile/toolkit -- were not targeted.  They need xarray/dask structures or
an external IMS token, which makes them integration-shaped rather than
unit-shaped; taking them on properly belongs in a batch with the data set
available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The physics that works is verified against its definitions: Haber's law on an
xarray field gives C x t exactly, ten Berge with n = 1 reduces to Haber, and
the log-normal dose response puts exactly half the population at TL_50 with
getToxicLoad as its precise inverse across seven fractions.  A decade either
side of TL_50 gives 0.02275 and 0.97725 -- exactly +-2 sigma for sigma = 0.5
in base 10, which pins both the logarithm base and the meaning of sigma.

Four defects:

  B36: the pandas default-units branch tests hasattr(field, "attrs"), which
       modern pandas satisfies, so it takes the xarray path and evaluates
       df.attrs[None] -> KeyError.  The documented mg/m3 default is
       unreachable.

  B37: and with inUnits supplied it returns all NaN, because
       concentrationField[:-1] keeps a DatetimeIndex while dt_min[1:] carries
       the RangeIndex from reset_index().  CalculatorHaber cannot produce a
       number for pandas input at all, despite a docstring that describes the
       path in detail.

  B38: InjuryLevelThreshold passes its threshold straight to ureg(), which
       parses strings only, so a plain number dies inside pint with
       "'float' object has no attribute 'replace'".  Its sibling accepts a
       plain TL_50 through tounit().

  B39: and getPercent compares tounit(x, units) against that threshold, so a
       numeric toxic load raises "Cannot operate with Quantity of different
       registries".  This is B12 from batch 1 with a concrete victim: the
       ordinary calling form cannot be evaluated.  Fixing B12 fixes this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lence cache

Correcting my own claim.  P1 in the findings log asserted that the cache lookup
in abstractcalculator "never finds a match".  Running it says otherwise, and
the truth is more interesting.

mongoengine recognises `all` as an operator even in the middle of the
flattened key, so params__all=["u","v"] becomes

    {"desc.params.0": {"$all": ["u"]}, "desc.params.1": {"$all": ["v"]}}

$all is defined as order-independent set containment; this is a positional
PREFIX match.  Against a stored params of [u, v, w] the requests ["u"],
["u","v"] and ["u","v","w"] all match, while ["v"], ["w"] and ["v","u"] miss --
every element present, wrong position.

So the cache is partially and order-dependently effective rather than dead.
_AllCalculatedParams is built by extend() in request order, so a hit depends on
the caller happening to ask for a prefix.  The miss is silent either way.

Both halves are pinned: the prefixes as passing tests, the order-independent
cases as xfail.  The findings log has been corrected to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
riskassessment reaches 39% and the package holds at 31%.  The floor does not
move: 38 covered statements is inside the rounding, and the batch's value was
four defects rather than a number -- in particular proof that B12's registry
split is not latent but actively breaks InjuryLevelThreshold.getPercent for
the ordinary numeric calling form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The season assignment is checked against seasonsdict -- the module's own
declaration of DJF/MAM/JJA/SON -- rather than against the pd.cut edges, so
changing either has to be deliberate.  All twelve months land correctly,
including the wrap-around that puts December and January in the same season,
and the HHMM encoding holds across the day.

Two modules cannot be imported, neither for want of an optional dependency:

  B40: radiosonde.py subclasses datalayer.ProjectMultiDBPublic, which no
       longer exists -- a leftover from an unfinished refactor.

  B41: highfreqdata/__main__.py reads
       /home/ilay/hera_unittest_data/.../slicedYamim_sonic.parquet at import
       time.  A personal absolute path, which CLAUDE.md forbids outright, so
       the module raises FileNotFoundError on every other machine.

GFS.py also fails to import here, but scikit-learn is declared in
requirements.txt; that one is the environment, not the code, and its test uses
importorskip so it is not miscounted as a defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meteorology reaches 35%, the package holds at 31%.  The substance was two
unimportable modules and a correction: P1 does not disable the turbulence
cache outright, it degrades $all into a positional-prefix match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The conversion itself is checked against independently known geography: Tel
Aviv at 34.78E 32.08N lands inside the ITM easting and northing ranges, the
round trip recovers the original to 1e-6 degrees, one degree of latitude comes
out at the expected ~111 km, and direction is preserved on both axes.

B42: importing hera.measurements.GIS.raster.hill2stl runs a demo at module
level -- under a comment that literally reads "# Run the function" -- printing
to stdout and writing an 8.1 MB test1.stl into whatever directory the process
happens to be in.  Mitigating detail, verified: importing the GIS package
alone does not reach it, only a direct import of that module.

Also pinned as a passing test rather than a defect: convertCRS returns a list
of shapely Points while TopographyToolkit.convertPointsCRS returns a
GeoDataFrame.  Both are documented, but two conversion entry points with
different return types is worth unifying.

My first draft of these tests assumed the GeoDataFrame form for both.  The
docstring says list; the tests now say list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_handleType1 cites Floors et al. (2021), WES 6, 1379, table a2, and its
seventeen IGBP entries all match it.  The physical ordering holds too: open
water is the floor at 1e-4 m, closed forest the ceiling at 1 m, urban rougher
than grassland, snow and ice nearly as smooth as water.

B43: getRoughnessAtPoint carries a SECOND table for the same codes, labelled
in the source as "# Example values".  It is an arithmetic ramp -- constant
steps of 0.05 or 0.1 from code 2 to 16 -- not a physical lookup, and it
disagrees with the published one by a factor of 100 for water and 1300 for
snow and ice.  It also inverts the ordering, making forest smoother than snow.

z0 feeds the wind profile and from there every dispersion result, so a caller
reaching the type_name="IGBP" branch does not get a slightly different answer;
they get a different simulation.  Both branches are reachable and the choice
is an argument.

This is the most scientifically consequential finding of the effort so far.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GIS reaches 43% and the package holds at 31%.  The floor does not move on 70
statements; the batch's substance was B43, two contradictory IGBP roughness
tables in one file, one cited to Floors et al. (2021) and one labelled
'Example values', differing by a factor of 1300 for snow and ice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md requires Calculator subclasses to carry pydoc.locate-compatible
names because Injury.py builds the class path from a string.  That contract is
exercised the way the factory uses it: the three calculators resolve, an
unknown name returns None rather than raising -- which is what the caller
checks -- and the resolved class is constructible.

B44: Parser_TOA5.parse has an empty body, so it returns None for any input,
including a path that does not exist.  It neither parses nor refuses, and a
real TOA5 parser already exists at
meteorology/highfreqdata/parsers/TOA5.ASCIIParser.

The module around it is orphaned: a scan of every .py file in the tree finds
nothing that imports hera.measurements.experiment.parsers, and the dispatch in
lowfreqdata/toolkit.py that would have built "Parser_{name}" paths is
commented out.  Recorded rather than acted on -- deleting it is a call for
someone who knows whether an external consumer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deliberately a small batch: experiment/parsers.py is orphaned, so covering its
41 definitions would raise a number without protecting anything anyone runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o end

The stub layer delivers what Phase 0 promised: openFoam.toolkit, OFWorkflow,
both LSM modules, hermesWorkflowToolkit and WRF all import under it, which is
the precondition for every future test of those modules.

prepareParams is checked against the conversions it documents -- a template
declaring metres turns 1 km into 1000, duration is exempted from
standardisation so 30 minutes does not silently become 1800 seconds, and grid
counts are cast to int.

B45: prepareParams does params = template_desc.get('params', {}) followed by
params.update(...), writing the caller's overrides into the template's own
dict.  An LSM template is meant to be reused across simulations, so after the
first run it carries that run's parameters and every later run inherits them.
Same shape as B19 in abstractToolkit.

Correcting myself: Phase 0 recorded that torch is unstubbed because
modelContainer.py subclasses torch.nn.Module and a MagicMock cannot be
inherited from.  That is wrong -- it imports LightningModuleHera, a plain
class, and the nn.Module reference is in a docstring.  The real reason,
verified: a leaf MagicMock has no __path__, so `import torch.utils` fails with
"'torch' is not a package".  Stubbing torch needs the namespace-package form
PyFoam gets.  _stubs.py now says that, and a test pins the mechanism instead
of asserting the claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    combined ............... 31% -> 32%   (12221 -> 12150 missed)

Final position across Phase 0 and nine batches: 1000 hermetic tests running in
about four seconds with no MongoDB, no S3 data and no network; floor 20 -> 30;
package coverage 22% -> 32%; 48 defects recorded, every one pinned by an
xfail(strict=True) so that fixing it makes the test demand the marker's
removal.

Also adds CONSOLIDATED-ISSUE-DRAFT.md -- a draft only, nothing opened or
posted.  It groups the 48 findings by what they are rather than by batch:
code that cannot run at all, scientific errors that change simulation output,
cross-context data corruption, infrastructure, and unmet API contracts.  It
ends with the six one-line fixes worth doing first, and with the two claims I
made during the work that turned out to be wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…import side effects

First production changes of this effort.  Every one of them removes something
that stopped tests or code from running; no behaviour was redesigned.

The integration suite now passes: 704 passed, 0 failed.  It had two failures
before, which meant the CI coverage gate never got far enough to execute.

  autocache.py -- `from hera import Project` at module level created a cycle.
      Importing autocache directly triggered hera.__getattr__("Project") ->
      _load_deferred(), whose step 3 re-imports autocache while it is still
      half-executed, so cacheFunction did not exist yet.  Project is used in
      three functions, so the import is now local to them and the cycle is
      gone at its source.

  autocache.py -- `return ret, doc if self.returnDoc else ret` parses as
      `return (ret, (doc if ... else ret))`, a 2-tuple either way, because the
      conditional binds tighter than the comma.  Callers got (result, result)
      instead of result.  Parenthesised; both returnDoc settings verified.

  evaporation, deposition, simulations/CLI.py -- nine `from ..utils import`
      sites resolved to hera.simulations.utils, whose __init__ is empty; the
      names live in hera.utils.  The two module-level ones made evaporation and
      deposition unimportable outright; the six inside CLI.py functions failed
      only when called.  A test now asserts no such import remains.

  hill2stl.py -- a demo ran at module level, printing to stdout and writing an
      8.1 MB test1.stl into the working directory on every import.  Guarded
      under __main__; the generator it demonstrates is still importable.

  highfreqdata/__main__.py -- read /home/ilay/hera_unittest_data/... at import
      time, so it raised FileNotFoundError on any other machine.  The path is
      an argument now, the read is under __main__.

  test_no_invalid_escapes.py -- it compiled each file with SyntaxWarning
      escalated to an error, but Python surfaces an escalated SyntaxWarning AS
      a SyntaxError, and the handler for genuinely unparseable legacy files
      caught it and called pytest.skip.  The failure it exists to detect could
      only ever become a skip.  Now two passes: parseability first with no
      filter, then escapes on a file known to parse.

  Meteorology.py, gasCloud.py -- with that test able to fail, it immediately
      found seven docstrings carrying the r prefix on the CLOSING triple
      quotes, making the LaTeX backslashes invalid escapes.  Moved to the
      opening quotes.  The tree now compiles warning-free apart from the three
      files that never parsed at all.

Fourteen xfail(strict=True) markers turned into real assertions, which is the
mechanism working as intended: fixing a pinned defect makes its test demand
the marker's removal.

Not fixed, deliberately.  B40 (radiosonde subclasses the removed
ProjectMultiDBPublic) needs the multi-database semantics redesigned, not a
mechanical edit.  monaghan.py needs pyriskassessment, a private package
declared in requirements.txt:372; it is skipped the way GFS.py is for sklearn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…port fix

Neither module could be imported before B31 was fixed, so nothing in them had
ever run.  Opening them to testing produced six defects, four of which mean
the code cannot work at all.

  B46: the diameter setter is named `ustar`, so @diameter.setter builds a new
       property under that name and replaces the real one.  obj.ustar returns
       _diameter, obj.diameter has no setter, and depositionRate_Petroff reads
       `ustar = self.ustar` -- it computes dry deposition with a particle
       diameter substituted for a friction velocity.  Passing ustar to the
       constructor changes nothing.

  B47: the heatFlux setter assigns self._ustar, so heat flux never changes and
       the friction velocity is silently overwritten.

  B48/B49: the deposition rate is bit-identically insensitive to surface
       roughness (z0 0.001 vs 0.5) and to particle density (500 vs 5000), both
       checked at a realistic u* so they are not artefacts of B46.

  B50: evaporationModels calls RiskToolkit.getAgent on the class, but it is an
       instance method, so every construction raises TypeError.  The class has
       never been instantiable.

  B51: flux_US strips the units off its temperature and then passes the bare
       number to vaporPressure, which requires units.  Every call raises
       DimensionalityError, so the module's central calculation cannot run.

The correlations themselves are right, and are checked against their published
forms: FSG and EPA diffusion match term for term, the FSG temperature exponent
is recovered as 1.75 from a log-log slope, air viscosity returns its reference
value exactly at 293 K and scales as sqrt(T), Reynolds is linear in velocity
and length, and Schmidt equals an independently computed nu/D.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A separate dead-code-cleanup effort landed on master and deleted
hill2stl.py (zero references at the time it ran). Accepting that
deletion rather than re-adding the file just to keep TestImportSideEffects
alive -- drop the test class and mark B42 resolved via deletion instead
of the __main__ guard it originally documented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resolves two collisions with unrelated concurrent work:
- hera/datalayer/autocache.py: our unblock-the-test-suite fix already
  supersedes master's circular-import fix (same root cause, plus an
  additional tuple-return bug); kept ours.
- hera/measurements/GIS/raster/hill2stl.py: deleted by a separate
  dead-code-cleanup effort as unreferenced; accepted the deletion and
  dropped TestImportSideEffects, which was its only remaining reference.

Also drops hera.simulations.LSM.hermesWorkflowToolkit from the stub-layer
import smoke test's module list -- that file was deleted by the same
dead-code cleanup as a stale diverged fork of
hera.simulations.hermesWorkflowToolkit.

Full unit suite verified green after the merge: 1076 passed, 3 skipped,
80 xfailed, 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@lior-antonov

Copy link
Copy Markdown
Collaborator

@ilayfalach is this ready for review? it seems that the tests are failing

Ilay Falach and others added 3 commits September 1, 2026 10:33
…xt pins

All three were verified against a fresh venv installed with the exact
pins from requirements.txt (Pint==0.24.4 etc), not just the shared dev
venv, which had silently drifted (e.g. Pint 0.25.3 instead of 0.24.4).

- test_gaussian_meteorology.py: escalate DeprecationWarning alongside
  SyntaxWarning. Python <=3.11 emits invalid-escape warnings as
  DeprecationWarning; only >=3.12 uses SyntaxWarning. CI runs 3.11.
- test_meteorology_module_imports.py: also importorskip("osgeo"). GFS.py
  imports both sklearn and osgeo at module level; the test only guarded
  the first.
- test_risk_injurylevel.py: under Pint==0.24.4, ureg("50") returns a bare
  int (not a Quantity), so InjuryLevelThreshold("T", threshold="50")
  raises AttributeError at .to(), not DimensionalityError. Verified
  directly against 0.24.4 in an isolated venv.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…portable

Discovered while validating the full unit suite against a fresh venv
pinned to requirements.txt: without a real local argos install (the case
CI's hermetic unit job is actually in), experiment.py's module-level
`class experimentSetupWithData(argosDataObjects.ExperimentZipFile, ...)`
crashes with NameError, because the old stub only covered a bare
`import argos` and left argosDataObjects undefined after the caught
ImportError -- no unit test currently imports experiment.py under a truly
argos-less environment, so this was never exercised.

argos.experimentSetup.dataObjects now gets the same namespace-package
treatment PyFoam has, plus real (if empty) placeholder classes for
ExperimentZipFile/TrialSet/Trial/EntityType/Entity, since a class
statement's bases must be actual types -- a MagicMock does not work as a
base class the way it works for attribute access or calls. Verified
against both a real local argos (unaffected, real one still wins) and a
venv with no argos at all (now imports cleanly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The integration job ("Run full test suite") only installed
requirements.txt, so its `pytest --cov=hera` and the later `coverage
combine`/`coverage report --fail-under` steps had no pytest-cov/coverage
available -- the job failed on its very first pytest invocation with
"unrecognized arguments: --cov=hera --cov-report=".

This job was always previously skipped (the unit job, gating it, used to
fail first), so this is the first time it has actually run for any branch
in this test-expansion effort. Can't be verified locally -- no MongoDB
service, S3 test data, or real hermes/pyargos checkout here -- so this is
reasoned from the error message and the working unit-job config, not
tested end-to-end before push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ilayfalach

Copy link
Copy Markdown
Collaborator Author

done, now you can look @lior-antonov

ilayfalach pushed a commit that referenced this pull request Sep 1, 2026
…tream

test_gis_hill2stl.py (dedicated coverage for compute_normal/write_triangle/
function/generate_solid_stl) collides with the same upstream deletion
already handled for test_gis_utils.py in batch10/PR #1067 -- hill2stl.py
no longer exists. Removed the file and updated the findings log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Discovered while cascading the pinned-venv validation into batch 22:
subclassing a bare MagicMock() attribute (hermes.workflow, when the real
hermes package is absent) does not raise -- it silently produces another
MagicMock as the "class". OFWorkflow.py's abstractWorkflow(hermes.workflow)
then becomes a MagicMock too, and every isinstance() check against a
workflow_Eulerian subclass elsewhere fails with a confusing
"TypeError: isinstance() arg 2 must be a type, a tuple of types, or a
union" instead of exercising the intended logic.

Generalizes the argos.experimentSetup.dataObjects fix: hermes now gets a
real module with workflow set to a real (if empty) placeholder class when
genuinely absent, same as before real installs still win.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@lior-antonov

Copy link
Copy Markdown
Collaborator

@ilayfalach please remove claude's residual files by adding the folders to .gitignore

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants