Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
433 changes: 311 additions & 122 deletions pyflowbatt/analysis.py

Large diffs are not rendered by default.

284 changes: 218 additions & 66 deletions pyflowbatt/battinfo.py

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions pyflowbatt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,9 @@ def main() -> None:
"--zip",
action="store_true",
help=(
"Zip the analysed folder afterwards and reference files in BattINFO metadata via "
"the zip's Zenodo download URL (for uploading one zip). Default: leave files "
"unzipped and reference each one via its own Zenodo download URL (for uploading "
"every file individually)."
"Package the analysed folder for Zenodo: zip it with an RO-Crate manifest inside, "
"and reference files in BattINFO metadata via the zip's Zenodo download URL. "
"Default: only analyse, without a zip or RO-Crate."
),
)
parser.add_argument(
Expand Down
128 changes: 116 additions & 12 deletions pyflowbatt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
from __future__ import annotations

import difflib
import functools
import json
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path

from pyflowbatt.version import __version__

try:
import tomllib
except ImportError:
Expand All @@ -30,6 +34,7 @@
# ------------ Warning/error messages for reading in settings files ---------- #

_TOML_ERROR_LINE_RE = re.compile(r"at line (\d+)")
_TOML_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
_TOML_TABLE_RE = re.compile(r"^\s*\[\s*([^\]]+?)\s*\]")
_TOML_KEY_RE = re.compile(r"""^\s*["']?([^"'=\s]+)["']?\s*=""")

Expand Down Expand Up @@ -137,6 +142,8 @@ def _toml_error_hint(error: str, line: str | None) -> str:
"area_cm2",
"assembled_resistance_ohm",
"summary_n_cycles",
"include_files",
"inherit",
},
"extra_patterns": set(_TECHNIQUE_KEYS),
"eis_tags": set(),
Expand Down Expand Up @@ -211,6 +218,33 @@ def _warn_misplaced_settings(data: dict, text: str, path: Path) -> None:
logger.warning("Ignoring %s %s (%s): %s", key, where, location, hint)


@functools.cache
def _warn_home_ignored(home_config: Path) -> None:
"""Warn once per run that settings are skipping the home config."""
logger.warning(
"Not using %s where a pyflowbatt.toml sets inherit = false, "
"e.g. in a folder unpacked from a PyFlowBatt zip",
home_config,
)


def _toml_key(key: str) -> str:
"""Format a key as TOML, quoted only when it is not a bare key like v_max."""
return key if _TOML_BARE_KEY_RE.match(key) else json.dumps(key)


def _toml_value(value: object) -> str:
"""Format a setting value as TOML."""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
if isinstance(value, list):
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
# JSON string escapes are valid in TOML basic strings
return json.dumps(value)


@dataclass
class PyFlowBattConfig:
r"""Configuration controlling how PyFlowBatt detects technique files and sample IDs.
Expand All @@ -229,6 +263,7 @@ class PyFlowBattConfig:
area_cm2 = 3.14 # electrode area used to normalise LSV resistance
assembled_resistance_ohm = 25000 # external resistor value used in the cell assembly
summary_n_cycles = [10, 20, 30, 40, 50] # cycle counts reported in the summary sheet
include_files = ["notes.txt", "photos/*.jpg"] # extra files packaged with --zip

[extra_patterns]
eis = ["*_EIS_*"] # adds to the built-in *_PEIS_*
Expand Down Expand Up @@ -286,12 +321,16 @@ class PyFlowBattConfig:
cv_min_r2: float = 0.8 # fit-quality gate below which CV capacitance isn't reported
# cycle counts reported in the summary sheet (N cycles avg. CE/EE/VE/capacity)
summary_n_cycles: list[int] = field(default_factory=lambda: [10, 20, 30, 40, 50])
# glob patterns for extra files described in the metadata and packaged with --zip
include_files: list[str] = field(default_factory=list)

@classmethod
def load(cls, folder: str | Path, *, home: Path | None = None) -> PyFlowBattConfig:
"""Load config from ~/pyflowbatt.toml, the parent folder, and the sample folder.

Returns a config with only built-in defaults when no config file exists.
A file setting ``inherit = false`` is complete on its own, so the files below it
in priority are skipped. Returns a config with only built-in defaults when no
config file exists.
"""
folder = Path(folder).resolve()
home = home if home is not None else Path.home()
Expand All @@ -304,11 +343,17 @@ def load(cls, folder: str | Path, *, home: Path | None = None) -> PyFlowBattConf
folder.parent / CONFIG_FILENAME,
folder / CONFIG_FILENAME,
]

for path in candidates:
if path.is_file():
logger.debug("Loading PyFlowBatt config from %s", path)
config._apply_toml(path)
parsed = [(path, *_read_toml(path)) for path in candidates if path.is_file()]
for i in range(len(parsed) - 1, 0, -1):
if parsed[i][1].get("inherit") is False:
if any(path == candidates[0] for path, _, _ in parsed[:i]):
_warn_home_ignored(candidates[0])
parsed = parsed[i:]
break

for path, data, text in parsed:
logger.debug("Loading PyFlowBatt config from %s", path)
config._apply_toml(path, data, text)

return config

Expand All @@ -332,10 +377,61 @@ def all_patterns(self) -> list[str]:
result.append(full)
return result

def _apply_toml(self, path: Path) -> None:
"""Merge a single TOML config file into this config (mutates in place)."""
data, text = _read_toml(path)
def to_resolved_toml(self) -> str:
"""Write these settings as a pyflowbatt.toml that needs no other config file.

Only settings that differ from the defaults are written, and inherit = false
stops home and parent configs filling in the rest when it is loaded.
"""
defaults = PyFlowBattConfig()
lines = [
f"## Settings PyFlowBatt {__version__} used here, combining all pyflowbatt.toml files.",
"## inherit = false: no other pyflowbatt.toml is combined with this one.",
"inherit = false",
]
for key in (
"sample_name",
"sample_name_pattern",
"extensions",
"lsv_threshold",
"area_cm2",
"assembled_resistance_ohm",
"summary_n_cycles",
"include_files",
):
value = getattr(self, key)
if value is not None and value != getattr(defaults, key):
lines.append(f"{key} = {_toml_value(value)}")

sections: dict[str, dict[str, object]] = {
"extra_patterns": {
toml_key: [p for p in getattr(self, attr) if p not in getattr(defaults, attr)]
for toml_key, attr in _TECHNIQUE_KEYS.items()
},
"eis_tags": dict(self.eis_tag_patterns),
"eis": {"model": self.eis_model, "summary_params": self.eis_summary_params},
"cv": {toml_key: getattr(self, attr) for toml_key, attr in _CV_KEYS.items()},
}
section_defaults: dict[str, dict[str, object]] = {
"eis": {"model": defaults.eis_model, "summary_params": defaults.eis_summary_params},
"cv": {toml_key: getattr(defaults, attr) for toml_key, attr in _CV_KEYS.items()},
}
for name, table in sections.items():
changed = {
key: value
for key, value in table.items()
if value != [] and value != section_defaults.get(name, {}).get(key)
}
if changed:
lines += ["", f"[{name}]"]
lines += [f"{_toml_key(key)} = {_toml_value(v)}" for key, v in changed.items()]
return "\n".join(lines) + "\n"

def _apply_toml(self, path: Path, data: dict, text: str) -> None:
"""Merge a single parsed TOML config file into this config (mutates in place)."""
_warn_misplaced_settings(data, text, path)
if "inherit" in data and not isinstance(data["inherit"], bool):
logger.warning("Ignoring bad inherit in %s (expected true or false)", path)

extra = _section(data, "extra_patterns")
for toml_key, attr in _TECHNIQUE_KEYS.items():
Expand Down Expand Up @@ -386,7 +482,7 @@ def _apply_toml(self, path: Path) -> None:
"Ignoring bad eis.summary_params in %s (expected list of strings)", path
)

for key in ("extensions", "extra_extensions"):
for key in ("extensions", "extra_extensions", "include_files"):
if key in data:
values = data[key]
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
Expand All @@ -395,9 +491,10 @@ def _apply_toml(self, path: Path) -> None:
if key == "extensions":
self.extensions = list(values)
else:
current = self.include_files if key == "include_files" else self.extensions
for v in values:
if v not in self.extensions:
self.extensions.append(v)
if v not in current:
current.append(v)

if "lsv_threshold" in data:
if isinstance(data["lsv_threshold"], int):
Expand Down Expand Up @@ -464,6 +561,8 @@ def _apply_toml(self, path: Path) -> None:
## The parent folder: applies to every sample folder inside
## The sample folder itself: only applies to that sample
## On conflicts, sample takes priority, then parent, then home.
## You can stop this behaviour and only use this file by setting inherit to false
# inherit = false

## --- Sample name (default: reads from battinfo, otherwise uses folder name) ---
# sample_name = "daves-new-sample-01" # hard-code an explicit sample name
Expand All @@ -485,6 +584,11 @@ def _apply_toml(self, path: Path) -> None:
## --- Cycle counts reported in the summary sheet ---
# summary_n_cycles = [10, 20, 30, 40, 50]

## --- Extra files to package with --zip ---
## Glob patterns, matched in each sample folder this file applies to.
## Otherwise only files used in analysis get zipped
# include_files = ["notes.txt", "photos/*.jpg"]

## --- Extra filename glob patterns per technique (added to the built-in patterns below) ---
# [extra_patterns]
# gcpl = ["*_cycling_*"] # built-in: *_GCPL_*
Expand Down
Loading
Loading