CoolFace
Datasetpublic

Scandium-Labs/Scandium-Dataset

Dataset Card — Scandium-Dataset v1.0.0 Summary Scandium-Dataset provides a harmonized, quality-scored foundation of DFT-computed structural and thermodynamic properties across 267,230 materials from Materials Project, OQMD, and JARVIS-DFT. It supports the early screening stage of battery materials discovery — filtering by phase stability, electronic structure, and structural family — before downstream property prediction (ionic conductivity, mechanical stability… See the full description on the dataset page: https://huggingface.co/datasets/Scandium-Labs/Scandium-Dataset.

sourceHugging Facecc-by-4.0updated 2mo agoView on Hugging Face
2likes2.4kdownloads
integrate_experimental_data.py441 linesDownload Raw Back to scripts
1"""Integrate experimental Li solid-electrolyte conductivity data.2 3Two separate, independently curated databases are supported:4 51. **Hargreaves et al. 2023** — npj Computational Materials6   ~820 entries, 403 compositions, 214 sources7   https://doi.org/10.1038/s41524-023-01137-38 92. **OBELiX (Therrien et al. 2025, NRC-Mila)**10   ~599 entries, curated with leakage-resistant splits11   pip install obelix-data12   https://github.com/nrc-mila/OBELiX13 14These are complementary — not duplicates — and are tracked as two separate15provenance sources with distinct citations.16 17Usage:18    # Hargreaves 202319    python scripts/integrate_experimental_data.py --ransom-path path/to/ransom2023.csv20 21    # OBELiX via pip package22    python scripts/integrate_experimental_data.py --obelix23 24    # Both25    python scripts/integrate_experimental_data.py --ransom-path ... --obelix26 27    # Dry run28    python scripts/integrate_experimental_data.py --dry-run29"""30import json, os, sys, time, argparse, csv, io, re, subprocess31from pathlib import Path32from collections import defaultdict33import numpy as np34import pandas as pd35import warnings36warnings.filterwarnings("ignore")37 38WIDTH = 6039 40RANSOM_URLS = [41    "https://raw.githubusercontent.com/nrc-cnrc/ransom2023-conductivity/main/data/conductivity_database.csv",42]43 44HARGREAVES_DOI = "https://doi.org/10.1038/s41524-022-00951-z"45OBELIX_DOI = "https://github.com/nrc-mila/OBELiX"46 47 48def parse_formula(formula):49    parts = re.findall(r'([A-Z][a-z]*)(\d*\.?\d*)', formula)50    return {el: float(cnt) if cnt else 1.0 for el, cnt in parts}51 52 53def formula_similarity(f1, f2):54    d1 = parse_formula(f1)55    d2 = parse_formula(f2)56    if set(d1.keys()) != set(d2.keys()):57        return False58    total1, total2 = sum(d1.values()), sum(d2.values())59    for el in d1:60        r1 = d1[el] / total161        r2 = d2[el] / total262        if abs(r1 - r2) > 0.05:63            return False64    return True65 66 67def try_fetch_ransom():68    """Try to download Hargreaves 2023 database."""69    import urllib.request70    for url in RANSOM_URLS:71        try:72            req = urllib.request.Request(url, headers={"User-Agent": "Scandium-Labs/1.0"})73            with urllib.request.urlopen(req, timeout=30) as resp:74                data = resp.read().decode("utf-8")75                print(f"  Downloaded {len(data):,} bytes")76                return data77        except Exception as e:78            print(f"  Failed: {str(e)[:80]}")79    return None80 81 82def try_fetch_obelix_package():83    """Try to install obelix-data package and load data."""84    try:85        import obelix86        ob = obelix.OBELiX(data_path="/tmp/obelix_rawdata", no_cifs=True)87        n = len(ob.dataframe)88        print(f"  OBELiX package loaded: {n} entries")89        return ob90    except ImportError:91        print("  obelix-data not installed. Attempting pip install...")92        result = subprocess.run(93            [sys.executable, "-m", "pip", "install", "obelix-data"],94            capture_output=True, text=True, timeout=6095        )96        if result.returncode == 0:97            try:98                import obelix99                ob = obelix.OBELiX(data_path="/tmp/obelix_rawdata", no_cifs=True)100                n = len(ob.dataframe)101                print(f"  OBELiX installed and loaded: {n} entries")102                return ob103            except Exception as e:104                print(f"  Load failed after install: {e}")105                return None106        else:107            print(f"  Install failed: {result.stderr[-200:]}")108            return None109 110 111def parse_ransom_csv(csv_data):112    """Parse Hargreaves 2023 CSV into entry dicts."""113    reader = csv.DictReader(io.StringIO(csv_data))114    entries = []115    for i, row in enumerate(reader):116        entry = {117            "source": "Hargreaves2023",118            "source_id": f"Hargreaves2023-{i:04d}",119            "is_experimental": True,120            "experimental_database": "Hargreaves2023",121            "provenance": {122                "source": "Hargreaves2023",123                "source_id": f"Hargreaves2023-{i:04d}",124                "doi": HARGREAVES_DOI,125                "integrated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),126            },127        }128        formula = row.get("Formula", row.get("formula", "")).strip()129        if formula:130            entry["formula"] = formula131            entry["structured_formula"] = formula132            entry["elements"] = list(parse_formula(formula).keys())133            entry["carrier_elements"] = ["Li"]134 135        for field in ["Conductivity_S_cm", "conductivity_S_cm", "Conductivity (S/cm)"]:136            val = row.get(field, "").strip()137            if val:138                try:139                    entry["conductivity_S_cm"] = float(val)140                except ValueError:141                    pass142 143        for field in ["Ea_eV", "activation_energy_eV", "Activation energy (eV)"]:144            val = row.get(field, "").strip()145            if val:146                try:147                    entry["activation_energy_eV"] = float(val)148                except ValueError:149                    pass150 151        for field in ["Temperature_K", "temperature_K", "Temperature (K)"]:152            val = row.get(field, "").strip()153            if val:154                try:155                    entry["temperature_K"] = float(val)156                except ValueError:157                    pass158 159        ref = row.get("Reference", row.get("reference", "")).strip()160        if ref:161            entry["reference"] = ref162            entry["provenance"]["experimental_reference"] = ref163 164        entries.append(entry)165 166    return entries167 168 169def parse_obelix_via_package(obelix_obj):170    """Parse OBELiX data via pandas DataFrame."""171    entries = []172    try:173        df = obelix_obj.dataframe174        for idx, row in df.iterrows():175            formula = str(row.get("Reduced Composition", ""))176            true_comp = str(row.get("True Composition", ""))177            conductivity = row.get("Ionic conductivity (S cm-1)")178            doi = str(row.get("DOI", ""))179            family = str(row.get("Family", ""))180            icsd = row.get("ICSD ID")181            sg = str(row.get("Space group", ""))182 183            entry = {184                "source": "OBELiX",185                "source_id": f"OBELiX-{idx}",186                "is_experimental": True,187                "experimental_database": "OBELiX_Therrien2025",188                "formula": formula,189                "structured_formula": true_comp if (true_comp and true_comp != "nan") else formula,190                "elements": list(parse_formula(formula).keys()) if formula else [],191                "carrier_elements": ["Li"],192                "conductivity_S_cm": float(conductivity) if pd.notna(conductivity) else None,193                "space_group": sg if sg != "nan" else "",194                "sse_family": family if family != "nan" else "",195                "reference": doi if doi != "nan" else "",196                "provenance": {197                    "source": "OBELiX_Therrien2025",198                    "source_id": f"OBELiX-{idx}",199                    "doi": "https://github.com/nrc-mila/OBELiX",200                    "icsd_id": str(icsd) if pd.notna(icsd) else "",201                    "integrated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),202                },203            }204            entries.append(entry)205    except Exception as e:206        print(f"  OBELiX DataFrame parse error: {e}")207 208    return entries209 210 211def cross_reference_and_add(exp_entries, all_dataset_entries):212    """Cross-reference experimental entries with the existing dataset."""213    formula_index = defaultdict(list)214    for e in all_dataset_entries:215        sf = e.get("structured_formula", e.get("formula", ""))216        formula_index[sf].append(e)217 218    matched = 0219    unmatched = 0220    conductivity_added = 0221    new_entries = []222 223    for exp_e in exp_entries:224        exp_formula = exp_e.get("formula", "")225        matched_entries = formula_index.get(exp_formula, [])226 227        if not matched_entries:228            for sf, existing in formula_index.items():229                if formula_similarity(exp_formula, sf):230                    matched_entries = existing231                    break232 233        db_name = exp_e.get("experimental_database", "unknown")234 235        if matched_entries:236            matched += 1237            for existing_e in matched_entries:238                if "ssb_screening" not in existing_e:239                    existing_e["ssb_screening"] = {}240 241                cond = exp_e.get("conductivity_S_cm")242                ea = exp_e.get("activation_energy_eV")243 244                if cond is not None:245                    existing_e["ssb_screening"]["estimated_ionic_conductivity_S_cm"] = cond246                    existing_e["ssb_screening"]["conductivity_source"] = f"experimental_{db_name}"247                    conductivity_added += 1248 249                if ea is not None:250                    existing_e["ssb_screening"]["experimental_activation_energy_eV"] = ea251 252                existing_e["is_experimental"] = True253                if "provenance" not in existing_e:254                    existing_e["provenance"] = {}255                existing_e["provenance"]["experimental_confirmed"] = True256                existing_e["provenance"]["experimental_database"] = db_name257                existing_e["provenance"]["experimental_reference"] = exp_e.get("reference", "")258        else:259            unmatched += 1260            new_entry = {261                "source": exp_e.get("source", "experimental"),262                "source_id": exp_e.get("source_id", f"exp-{unmatched}"),263                "formula": exp_formula,264                "structured_formula": exp_formula,265                "elements": exp_e.get("elements", []),266                "nsites": len(exp_e.get("elements", [])),267                "band_gap": None,268                "formation_energy_per_atom": None,269                "energy_above_hull": None,270                "is_experimental": True,271                "families": ["experimental_SSE"],272                "sse_family": "experimental",273                "mobile_ion": "Li",274                "carrier_elements": ["Li"],275                "tier": "experimental_gold",276                "quality_score": 95,277                "quality_flags": ["experimental_data", "has_conductivity"],278                "ssb_screening": {279                    "estimated_ionic_conductivity_S_cm": exp_e.get("conductivity_S_cm"),280                    "conductivity_source": f"experimental_{db_name}",281                    "experimental_activation_energy_eV": exp_e.get("activation_energy_eV"),282                    "measurement_temperature_K": exp_e.get("temperature_K"),283                    "mobile_ion": "Li",284                    "sse_family": "experimental",285                    "gates_passed": ["experimental"],286                    "sse_candidate_score": 100,287                },288                "provenance": exp_e.get("provenance", {}),289                "license": "CC-BY-4.0",290            }291            new_entries.append(new_entry)292 293    return matched, unmatched, conductivity_added, new_entries294 295 296def main():297    parser = argparse.ArgumentParser(description="Integrate experimental conductivity data")298    parser.add_argument("--ransom-path", type=str, default=None,299                        help="Path to Hargreaves 2023 CSV file")300    parser.add_argument("--obelix", action="store_true",301                        help="Try to load OBELiX via obelix-data package")302    parser.add_argument("--dry-run", action="store_true")303    parser.add_argument("--cross-ref-only", action="store_true")304    args = parser.parse_args()305 306    if not args.ransom_path and not args.obelix:307        print("Specify at least one data source:")308        print("  --ransom-path <file.csv>  Hargreaves et al. 2023 database")309        print("  --obelix                  OBELiX via obelix-data package")310        sys.exit(1)311 312    BASE_DIR = Path(__file__).resolve().parent.parent313    DATASET_PATH = BASE_DIR / "dataset"314 315    print("=" * WIDTH)316    print("  EXPERIMENTAL DATA INTEGRATION")317    print("=" * WIDTH)318 319    all_experimental = []320 321    # --- Hargreaves 2023 ---322    if args.ransom_path:323        source_label = "Hargreaves et al. 2023 (npj Comput. Mater.)"324        print(f"\n  [{source_label}]")325 326        ransom_data = None327        path = Path(args.ransom_path)328        if path.exists():329            with open(path) as f:330                ransom_data = f.read()331            print(f"  Loaded from {path}")332        else:333            print(f"  File not found: {path}")334            print("  Attempting download...")335            ransom_data = try_fetch_ransom()336 337        if ransom_data:338            entries = parse_ransom_csv(ransom_data)339            print(f"  Parsed {len(entries):,} entries")340            for e in entries:341                e["experimental_database"] = "Hargreaves2023"342            all_experimental.extend(entries)343            with_cond = sum(1 for e in entries if e.get("conductivity_S_cm") is not None)344            with_ea = sum(1 for e in entries if e.get("activation_energy_eV") is not None)345            print(f"    With conductivity: {with_cond}")346            print(f"    With activation energy: {with_ea}")347        else:348            print(f"  Could not load Hargreaves 2023 data.")349            print(f"  Download manually from: {HARGREAVES_DOI}")350 351    # --- OBELiX Therrien 2025 ---352    if args.obelix:353        source_label = "OBELiX (Therrien et al. 2025, NRC-Mila)"354        print(f"\n  [{source_label}]")355        print("  Attempting obelix-data package...")356        ob_data = try_fetch_obelix_package()357        if ob_data is not None:358            entries = parse_obelix_via_package(ob_data)359            print(f"  Parsed {len(entries):,} entries")360            for e in entries:361                e["experimental_database"] = "OBELiX_Therrien2025"362            all_experimental.extend(entries)363            with_cond = sum(1 for e in entries if e.get("conductivity_S_cm") is not None)364            with_ea = sum(1 for e in entries if e.get("activation_energy_eV") is not None)365            print(f"    With conductivity: {with_cond}")366            print(f"    With activation energy: {with_ea}")367        else:368            print(f"  Could not load OBELiX via package.")369            print(f"  Try: pip install obelix-data")370            print(f"  Or:  https://github.com/nrc-mila/OBELiX")371 372    if not all_experimental:373        print("\n  No experimental data loaded. Nothing to integrate.")374        sys.exit(1)375 376    # --- Cross-reference with existing dataset ---377    print(f"\n  Loading Scandium-Dataset...")378    t0 = time.time()379    with open(DATASET_PATH / "entries_final_v3.json") as f:380        all_entries = json.load(f)381    print(f"  {len(all_entries):,} entries ({time.time()-t0:.1f}s)")382 383    print(f"\n{'─' * WIDTH}")384    print("  Cross-referencing...")385    print(f"{'─' * WIDTH}")386 387    matched, unmatched, conductivity_added, new_entries = cross_reference_and_add(388        all_experimental, all_entries389    )390 391    print(f"\n  Results:")392    print(f"    Matched existing entries: {matched}")393    print(f"    Unmatched (new compositions): {unmatched}")394    print(f"    Conductivity labels added: {conductivity_added}")395    print(f"    New experimental entries: {len(new_entries)}")396 397    if new_entries:398        cond_entries = [(e.get("formula", "?"),399                         e.get("ssb_screening", {}).get("estimated_ionic_conductivity_S_cm"))400                        for e in new_entries401                        if e.get("ssb_screening", {}).get("estimated_ionic_conductivity_S_cm")]402        for formula, cond in sorted(cond_entries, key=lambda x: -abs(x[1] or 0))[:5]:403            if cond:404                print(f"    {formula:30s} σ={cond:.2e} S/cm")405 406    if not args.dry_run:407        if new_entries:408            all_entries.extend(new_entries)409            print(f"\n  Added {len(new_entries):,} experimental entries")410 411        output_path = DATASET_PATH / "entries_final_v3.json"412        print(f"  Writing to {output_path}...")413        t_write = time.time()414        with open(output_path, "w") as f:415            json.dump(all_entries, f)416        print(f"  Done ({time.time()-t_write:.1f}s)")417 418        experimental_count = sum(1 for e in all_entries if e.get("is_experimental"))419        with_conductivity_total = sum(420            1 for e in all_entries421            if e.get("ssb_screening", {}).get("estimated_ionic_conductivity_S_cm")422        )423 424        print(f"\n{'─' * WIDTH}")425        print("  INTEGRATION SUMMARY")426        print(f"{'─' * WIDTH}")427        db_sources = set(e.get("experimental_database", "unknown") for e in all_experimental)428        for db in sorted(db_sources):429            count = sum(1 for e in all_experimental if e.get("experimental_database") == db)430            print(f"  {db}: {count} entries")431        print(f"  Total experimental entries in dataset: {experimental_count}")432        print(f"  Entries with conductivity labels: {with_conductivity_total}")433    else:434        print(f"\n  (dry-run)")435 436    print("=" * WIDTH)437 438 439if __name__ == "__main__":440    main()441