Multimodal materials representation and inverse design
+
Bring your crystal structures and properties together in one shared representation. Explore it, design new materials from the properties you want, and benchmark the result.
+
+
+
+
+
+ MEIDNet in one minute: data โ model โ family โ rules โ targets โ search โ candidates. The same tour opens on your first visit to the Studio.
+
+
+
+
Datayour materials
Modelshared space
+
Familycrystal type
Ruleschemistry
+
Targetswhat you want
Searchinverse design
+
Candidatesnew materials
+
+
+
+
Explore
Datasets, properties and modalities that work with MEIDNet today, and what is planned.
+ Published in npj Computational MaterialsOpen source, MITBit-exact regression against the paperโs code
+ Three pretrained checkpointsMLIP stability screening (MACE)Runs on a laptop CPU
+
+
+
+
Citation
+
A. Babu, R. A. Gouvรชa, P. Vandergheynst and G.-M. Rignanese, MEIDNet: Multimodal generative AI framework for inverse materials design, npj Computational Materials (2026). doi:10.1038/s41524-026-02153-3 ยท arXiv:2601.22009
+
+
@article{meidnet2026,
+ title = {MEIDNet: Multimodal generative AI framework for inverse materials design},
+ author = {Anand Babu and Rog{\'e}rio Almeida Gouv{\^e}a and Pierre Vandergheynst and Gian-Marco Rignanese},
+ journal = {npj Computational Materials},
+ year = {2026},
+ doi = {10.1038/s41524-026-02153-3}
+}
+
+
+
+
+
+
diff --git a/meidnet/studio/server.py b/meidnet/studio/server.py
index e2ef305b4ec8c7fca1fc15c24775955ca81070ad..86b8ef09c654d18458a983aac19d84495a92a03c 100644
--- a/meidnet/studio/server.py
+++ b/meidnet/studio/server.py
@@ -300,6 +300,7 @@ class Studio:
return {
"version": __version__, "public": self.public, "limits": PUBLIC_LIMITS if self.public else None,
"docs_url": "/docs/" if self.docs_dir else None,
+ "home_url": "/" if self.docs_dir else None, # the landing page exists in platform mode only
"config": self._visible_config(cfg),
"model": self._model_info(lm, own=self._own(s)),
"ranges": property_ranges(lm),
@@ -999,7 +1000,7 @@ class Studio:
return {"available": False, "note": "run a search first"}
name, variant = job.family or (self._base_cfg(s).generation.family, self._base_cfg(s).generation.variant)
fam = load_family(self._family_src(name), variant=variant, default_variant=True)
- return cs.candidates_dataset(job.candidates, fam, run_dir=job.run_dir)
+ return cs.candidates_dataset(job.candidates, fam, run_dir=job.run_dir, lm=self._model_for(s))
if s and s.check_info and s.cfg:
if not any(getattr(r, "structure", None) is not None for r in s.check_info["records"]):
return {"available": False, "note": "no usable structures in this table - see the Data block"}
@@ -1062,6 +1063,13 @@ def _finite(obj):
return obj
+def _page(name: str) -> str:
+ """One of the two pages shipped with the package (studio.html, landing.html), read on every request so that
+ edits show without a restart."""
+ with open(os.path.join(HERE, name), "r", encoding="utf-8") as f:
+ return f.read()
+
+
class Handler(BaseHTTPRequestHandler):
studio: Studio = None
timeout = 120 # seconds a client may stay silent mid-request
@@ -1115,8 +1123,17 @@ class Handler(BaseHTTPRequestHandler):
s = self.studio
try:
if path in ("/", "/index.html"):
- with open(os.path.join(HERE, "studio.html"), "r", encoding="utf-8") as f:
- return self._send(200, f.read(), "text/html")
+ # platform mode (a docs site is served): the landing page; deep links keep working via /studio/
+ if s.docs_dir and not any(k in q for k in ("panel", "explore", "tour")):
+ return self._send(200, _page("landing.html"), "text/html")
+ if s.docs_dir:
+ self.send_response(302)
+ self.send_header("Location", "/studio/" + ("?" + u.query if u.query else ""))
+ self.end_headers()
+ return
+ return self._send(200, _page("studio.html"), "text/html")
+ if path in ("/studio", "/studio/", "/studio/index.html"):
+ return self._send(200, _page("studio.html"), "text/html")
if path == "/api/state":
return self._send(200, s.state(sid))
if path == "/api/data":
@@ -1250,9 +1267,10 @@ def serve(cfg: MEIDNetConfig | None, model_path: str | None = None, port: int =
threading.Thread(target=warm_up, args=(Handler.studio,), daemon=True).start()
threading.Thread(target=_clean_periodically, args=(Handler.studio,), daemon=True).start()
url = f"http://{'127.0.0.1' if host in ('0.0.0.0', '') else host}:{port}/"
- print(f"MEIDNet Studio {'(public mode) ' if public else ''}running at {url} (Ctrl+C to stop)", flush=True)
+ studio_url = url + ("studio/" if docs_dir else "")
+ print(f"MEIDNet Studio {'(public mode) ' if public else ''}running at {studio_url}" + (f" (home page {url}, docs {url}docs/)" if docs_dir else "") + " (Ctrl+C to stop)", flush=True)
if open_browser and not public:
- threading.Timer(0.8, lambda: webbrowser.open(url)).start()
+ threading.Timer(0.8, lambda: webbrowser.open(studio_url)).start()
try:
httpd.serve_forever()
except KeyboardInterrupt:
diff --git a/meidnet/studio/studio.html b/meidnet/studio/studio.html
index b112bcc164727556b48462f61eb26c9ae368a059..176ebbc36757bb5fbba190ad2dc5cc94e3fb59ab 100644
--- a/meidnet/studio/studio.html
+++ b/meidnet/studio/studio.html
@@ -76,6 +76,8 @@ h2{font-size:21px;margin:0 0 4px;letter-spacing:-.01em}h3{font-size:15px;margin:
.io .arr{display:flex;align-items:center;color:var(--accent);font-size:20px;background:none;border:none;padding:0}
@media(max-width:700px){.io{grid-template-columns:1fr}.io .arr{display:none}}
body:not(.beginner) .beginner-only{display:none}
+body.beginner .researcher-only{display:none} /* the YAML drawer and the Python behind each block: Researcher mode */
+.home{text-decoration:none;font-size:20px;color:var(--c-candidates);font-weight:800;padding:0 4px}
.help{border-left:3px solid var(--accent);background:var(--accent-soft);padding:6px 10px;margin:8px 0;color:var(--muted);font-size:13px;border-radius:0 8px 8px 0}
.plain{border:1px dashed var(--accent);border-radius:10px;padding:8px 12px;margin:0 0 10px;font-size:13.5px}
.muted{color:var(--muted)}.small{font-size:12.5px}.flag{color:#8a5a00}:root[data-theme="dark"] .flag{color:var(--warn)}
@@ -141,19 +143,54 @@ body>div:empty:not([id]):not([class]){position:absolute;top:0;left:0;width:0;hei
.byod{border-left:4px solid var(--c-data)}.map-form .pr{display:flex;gap:8px;align-items:center;margin:4px 0;flex-wrap:wrap}
.loss{display:grid;grid-template-columns:1fr 1fr;gap:10px}@media(max-width:700px){.loss{grid-template-columns:1fr}}
.cands{display:block}
+/* tour: "How MEIDNet works" โ the stage is SVG drawn from a function of time (see the tour section of the script) */
+.tour{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(240px,1fr);gap:16px;align-items:start;border-top:4px solid var(--tour-c,var(--c-family));--tour-c:var(--c-family)}
+@media(max-width:860px){.tour{grid-template-columns:1fr}}
+.tour-stage{width:100%;height:auto;display:block;border-radius:10px;background:var(--bg);border:1px solid var(--line)}
+.tour-side .eyebrow{color:color-mix(in oklab,var(--tour-c) 70%,var(--ink))}
+.tour-side h2{font-size:19px;margin:0 0 2px}
+.tour-cap{font-size:16.5px;line-height:1.45;min-height:4.4em;margin:8px 0 10px}
+.tour-ch{list-style:none;margin:0 0 10px;padding:0;display:flex;flex-wrap:wrap;gap:5px}
+.tour-ch button{padding:2px 9px;font-size:12px;border-radius:999px;border-color:color-mix(in oklab,var(--cc) 45%,var(--line));color:color-mix(in oklab,var(--cc) 75%,var(--ink))}
+.tour-ch button.on{background:var(--cc);border-color:var(--cc);color:#fff;font-weight:700}
+.tour-ctl{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
+.tour-prog{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin:10px 0 2px;cursor:pointer}
+.tour-prog i{display:block;height:100%;background:var(--tour-c);width:0}
+.node.tour-focus .box{stroke:var(--nc);stroke-width:3;fill:color-mix(in oklab,var(--nc) 12%,var(--card));filter:drop-shadow(0 0 7px color-mix(in oklab,var(--nc) 65%,transparent))}
+@media(max-width:600px){.tour-cap{font-size:15.5px;min-height:0}}
+.tw-narrow .tw-ms text{display:none} /* on a phone the strip at the top of the stage keeps its colours, the caption has the words */
- MEIDNet Studioloadingโฆ
+ โMEIDNet Studioloadingโฆ
-
+
+
-
+
+
+
+
+
How it works ยท 1 minute
+
From your wish to new materials
+
+
+
+
+
+
+
+
+
+
+
+
+
Change anything above or in a block โ what it does to the blocks after it appears here.
Flow log (0)
@@ -256,7 +293,7 @@ function initPrefs(){
$('theme-btn').textContent = document.documentElement.dataset.theme==='dark' ? 'โ๏ธ' : '๐';
}
function toggleTheme(){ const d=document.documentElement.dataset.theme==='dark'; document.documentElement.dataset.theme = d?'light':'dark'; try{localStorage.setItem('meidnet_theme', d?'light':'dark');}catch(e){} $('theme-btn').textContent = d?'๐':'โ๏ธ'; mountAll3d(); }
-function setBeginner(on, silent){ document.body.classList.toggle('beginner', on); $('beg-switch').classList.toggle('on', on); try{localStorage.setItem('meidnet_beginner', on?'1':'0');}catch(e){} if(!silent){ renderLegend(); } }
+function setBeginner(on, silent){ document.body.classList.toggle('beginner', on); $('beg-switch').classList.toggle('on', !on); try{localStorage.setItem('meidnet_beginner', on?'1':'0');}catch(e){} if(!silent){ renderLegend(); } }
function midRange(p){ const r=S.state.ranges[p]; return r ? +((r[0]+r[1])/2).toFixed(2) : 0; }
function uiFromConfig(g, props){
g = g || {};
@@ -307,6 +344,7 @@ function renderHeader(){
const links=[S.state.docs_url?`Docs`:'', 'Code', 'Paper'].filter(Boolean).join(' ยท ');
const which = S.state.model.own ? 'your model' : (S.state.model.legacy ? 'published Perov-5 model' : S.state.model.path);
$('hdr-sub').innerHTML = esc(which + ' ยท ' + props.map(p=>p.label).join(', ')) + ' ยท ' + links;
+ $('home-link').hidden = !(S.state.home_url && !STATIC);
}
/* Load the family (or install one already fetched). Its rules always start from the family's own definition -
@@ -630,7 +668,7 @@ function ioStrip(block){
}
function behind(block){
const y=behindYaml(block), p=behindPython(block);
- return `Behind the scenes: the YAML and Python of this block
${esc(y)}
${esc(p)}
`;
+ return `Behind the scenes: the YAML and Python of this block
${esc(y)}
${esc(p)}
`;
}
function behindTab(btn, which){ const d=btn.closest('.behind'); d.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on', b===btn)); d.querySelectorAll('pre').forEach(p=>p.hidden = p.dataset.tab!==which); }
function behindYaml(block){
@@ -924,7 +962,7 @@ function renderLive(){
L.innerHTML=b;
}
function renderLegend(){ const beg=document.body.classList.contains('beginner');
- $('legend').innerHTML = beg ? `
Glossary
Composition
one element per site of the prototype, e.g. CsPbBrโ.
Rule
a hard check with a measured value and an allowed window.
Target
a property value you want; the model predicts how close each composition gets.
Latent space
the hidden coordinates where structures and properties meet; the search moves through it.
Extrapolation
a target outside the training range โ predictions there are guesses.
Switch off Beginner mode in the header for a denser view.
`
+ $('legend').innerHTML = beg ? `
Glossary
Composition
one element per site of the prototype, e.g. CsPbBrโ.
Rule
a hard check with a measured value and an allowed window.
Target
a property value you want; the model predicts how close each composition gets.
Latent space
the hidden coordinates where structures and properties meet; the search moves through it.
Extrapolation
a target outside the training range โ predictions there are guesses.
Switch on Researcher mode in the header for the configuration as text and the Python behind each block.
`
: `
How to use
Family: pick the crystal type; exclude elements.
Rules: move limits; counts update live.
Targets: set values; see the closest compositions.
Search runs the latent optimisation; Candidates explains each result.
Data/Model: upload your table, train, and design with your own properties.
`; }
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโ actions โโโโโโโโโโโโโโโโโโโโโโโโโโโ */
@@ -1061,7 +1099,10 @@ function csDataset(what){
push('formula', c.formula); push('score', c.score); push('round', c.round); push('still_passes', candStillPasses(c)?1:0);
for(const [k,v] of Object.entries(c.predictions||{})) push('pred_'+k, v); for(const [k,v] of Object.entries(c.target_values||{})) push('target_'+k, v);
for(const r of c.constraint_results) if(r.value!==null) push('rule_'+r.name, r.value); });
- const props={}; for(const [k,v] of Object.entries(P)) if(v.length===structures.length) props[k]={target:'structure', values:v, units:'', description:k};
+ const words={formula:'composition', score:'distance to the target (lower is closer)', round:'search round', still_passes:'1 if it passes the rules as they are now'};
+ const describe=k=>{ const m=k.match(/^(pred|target)_(.+)$/); // the same words as meidnet.studio.chemiscope.candidates_dataset
+ return m ? {units:unit(m[2]).trim(), description:(m[1]==='pred'?'predicted ':'target ')+label(m[2])} : {units:'', description:words[k]||k}; };
+ const props={}; for(const [k,v] of Object.entries(P)) if(v.length===structures.length) props[k]={target:'structure', values:v, ...describe(k)};
const pk=Object.keys(props).filter(k=>k.startsWith('pred_'));
return {dataset:{meta:{name:'Candidates found by the search', description:`${structures.length} candidate(s) that passed every rule`, authors:['Anand Babu']}, structures, properties:props,
settings:{map:{x:{property:pk[0]||'score'}, y:{property:pk[1]||'score'}, color:{property:'score', palette:'viridis'}}, structure:[{bonds:true, unitCell:true, supercell:[1,1,1], keepOrientation:true}]}}, index:idx};
@@ -1114,12 +1155,13 @@ function fallbackExplore(ds, sel){
let s=``;
+ const axis=k=>{ const p=ds.properties[k]||{}; return String(p.description||k).replace(/\s*\(structure encoder\)$/,'')+(p.units?' ('+p.units+')':''); }; // words, not column keys
+ s+=`${esc(axis(px))}${esc(axis(py))}`;
fb.innerHTML=`
The chemiscope viewer could not be loaded (offline?). Showing the built-in viewer: click a point, drag the cell to rotate.
${s}
`;
fbSelect(sel!==undefined?sel:0);
}
function fbSelect(i){ const ds=S.csData; if(!ds||!ds.structures[i]) return; const st=fromChemiscope(ds,i); S.cells['fb']=st; mountCell($('c3d-fb'), st, {title: ds.properties.formula?ds.properties.formula.values[i]:'', labels:true});
- $('fb-info').innerHTML = Object.entries(ds.properties).map(([k,p])=>`${esc(k)}: ${typeof p.values[i]==='number'?fmt(p.values[i]):esc(p.values[i])}${p.units?' '+esc(p.units):''}`).join(' ยท '); }
+ $('fb-info').innerHTML = Object.entries(ds.properties).map(([k,p])=>`${esc(k)}: ${typeof p.values[i]==='number'?fmt(p.values[i]):esc(p.values[i])}${p.units?' '+esc(p.units):''}`).join(' ยท '); }
function closeExplore(){ $('explore').hidden=true; if(S.cs){ try{ S.cs.remove(); }catch(e){} S.cs=null; } }
function downloadDataset(){ if(!S.csData) return; const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([JSON.stringify(S.csData)],{type:'application/json'})); a.download=`meidnet_${S.csWhat||'dataset'}_chemiscope.json`; a.click(); }
@@ -1136,10 +1178,357 @@ function inspect(formula){ const r=S.fam.space.rows.find(x=>x.f===formula); if(!
}
function closeModal(){ const was=$('modal').classList.contains('open'); $('modal').classList.remove('open');
if(was && S.modalReturn && document.body.contains(S.modalReturn)) S.modalReturn.focus(); }
+
+/* โโโโโโโโโโโโโโโโโโโโโโโโโโโ tour: the whole workflow in one minute โโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+/* The stage is SVG drawn from a pure function of time, tourFrame(ms). The page, the chapter jumps, the still frames
+ for reduced motion and the recorded video (tourRecord) all use the same frames. Scenes: TOUR_SCENES. */
+const TOUR = {ms:0, playing:false, last:0, drawn:0, raf:0, i:-1, total:0, visible:true, reduced:false, open:false};
+const TW = {
+ clamp:(x,a=0,b=1)=>Math.max(a,Math.min(b,x)),
+ seg:(t,a,b)=>Math.max(0,Math.min(1,(t-a)/(b-a))), // progress of t inside [a, b]
+ ease:x=>{ x=Math.max(0,Math.min(1,x)); return x<.5 ? 4*x*x*x : 1-Math.pow(-2*x+2,3)/2; },
+ out:x=>{ x=Math.max(0,Math.min(1,x)); return 1-Math.pow(1-x,3); },
+ pop:x=>{ x=Math.max(0,Math.min(1,x)); const c=1.70158; return 1+(c+1)*Math.pow(x-1,3)+c*Math.pow(x-1,2); },
+ lerp:(a,b,x)=>a+(b-a)*x,
+ rng:seed=>()=>{ seed|=0; seed=seed+0x6D2B79F5|0; let t=Math.imul(seed^seed>>>15,1|seed); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; },
+};
+const twC = id => BLOCKS.some(b=>b.id===id) ? `var(--c-${id})` : 'var(--c-family)';
+const twN = v => +(+v).toFixed(1); // short coordinates
+/* drawing pieces (all return SVG markup) */
+function twText(x, y, str, o={}){ return `${esc(str)}`; }
+function twRect(x, y, w, h, o={}){ return ``; }
+function twChip(x, y, label, color, o={}){ const w=o.w||Math.max(44, 13*String(label).length+26), h=o.h||36;
+ return `${twRect(x-w/2, y-h/2, w, h, {r:h/2, fill:o.filled?color:'var(--card)', stroke:color, sw:2})}${twText(x, y+7.5, label, {anchor:'middle', size:o.size||20, weight:700, fill:o.filled?'#fff':color})}`; }
+function twArrow(x1, y1, x2, y2, color, p=1, o={}){ if(p<=0) return ''; const x=TW.lerp(x1,x2,p), y=TW.lerp(y1,y2,p), a=Math.atan2(y2-y1,x2-x1), k=o.head||11;
+ return ``+
+ ``; }
+function twCheck(x, y, ok, p=1){ if(p<=0) return ''; const s=TW.pop(p);
+ return ``+
+ (ok ? `` : ``)+''; }
+/* a crystal from the built-in cell viewer, placed on the stage; elements = {A:'Cs', B:'Pb', X:'I'} or the family's first choice */
+function twCell(x, y, size, o={}){
+ let st;
+ if(!o.names && S.fam && S.fam.sites){ const el={}; for(const [g,grp] of Object.entries(S.fam.groups)) el[g]=(o.elements&&o.elements[g])||grp.sample.find(e=>!S.ui||!S.ui.exclude.has(e))||grp.sample[0];
+ const a=(S.fam.lattice&&S.fam.lattice.reference_a)||6; st=buildStruct(S.fam.sites.map(s=>el[s.group]), S.fam.sites.map(s=>s.frac), latticeMatrix(S.fam.lattice, a), S.fam.sites.map(s=>s.group)); }
+ if(o.names || !st){ const e=Object.assign({A:'Cs', B:'Pb', X:'I'}, o.elements||{}); // given names, or before the family has loaded: cubic ABX3
+ const nm=o.names||[e.A,e.B,e.X,e.X,e.X];
+ st=buildStruct(nm, [[0,0,0],[.5,.5,.5],[.5,.5,0],[.5,0,.5],[0,.5,.5]], latticeMatrix({lattice:'cubic'}, 6.3), ['A','B','X','X','X']); }
+ const svg=miniCell(st, {size:200, yaw:o.yaw??-0.55, pitch:o.pitch??0.35, labels:o.labels, title:o.title||''});
+ return svg.replace('