From 8b2177ae68d5af905b3419f7f0556526245d4b36 Mon Sep 17 00:00:00 2001 From: esaruoho Date: Tue, 3 Mar 2026 10:22:23 +0200 Subject: [PATCH 1/3] smooth relay contact resistance during switching transition Relay contacts (RelayContactElm) now interpolate resistance smoothly over the switching time instead of snapping instantly between r_on and r_off. The coil (RelayCoilElm) computes a fractional d_position that ramps linearly during state transitions, and passes it to linked contacts via setPosition(). Each contact derives its own fractional position (accounting for normally-closed inversion) and uses: resistance = r_on + (r_off - r_on) * d_position This gives a smooth visual and electrical transition matching the built-in relay behavior. Latching relay direction and motor protection switch instant-switching are handled correctly. Addresses sharpie7/circuitjs1#983. Co-Authored-By: Claude Opus 4.6 --- .../client/MotorProtectionSwitchElm.java | 4 +- .../circuitjs1/client/RelayCoilElm.java | 40 +++++++++++-- .../circuitjs1/client/RelayContactElm.java | 58 +++++++++++-------- 3 files changed, 72 insertions(+), 30 deletions(-) diff --git a/src/com/lushprojects/circuitjs1/client/MotorProtectionSwitchElm.java b/src/com/lushprojects/circuitjs1/client/MotorProtectionSwitchElm.java index 1d047e51b..8085a9474 100644 --- a/src/com/lushprojects/circuitjs1/client/MotorProtectionSwitchElm.java +++ b/src/com/lushprojects/circuitjs1/client/MotorProtectionSwitchElm.java @@ -245,12 +245,14 @@ void startIteration() { void setSwitchPositions() { int i; int switchPosition = (blown) ? 0 : 1; + // motor protection switch transitions instantly (d_position is 0 or 1) + double d = 1 - switchPosition; for (i = 0; i != sim.elmList.size(); i++) { Object o = sim.elmList.elementAt(i); if (o instanceof RelayContactElm) { RelayContactElm s2 = (RelayContactElm) o; if (s2.label.equals(label)) - s2.setPosition(switchPosition, RelayCoilElm.TYPE_NORMAL); + s2.setPosition(switchPosition, d, RelayCoilElm.TYPE_NORMAL); } } } diff --git a/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java b/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java index 50d867c0c..33bd465f7 100644 --- a/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java +++ b/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java @@ -289,7 +289,7 @@ void startIteration() { double a = Math.exp(-sim.timeStep*1e3); avgCurrent = a*avgCurrent + (1-a)*absCurrent; int oldSwitchPosition = switchPosition; - + if (state == 0) { if (avgCurrent > onCurrent) { lastTransition = sim.t; @@ -311,7 +311,7 @@ else if (sim.t-lastTransition > switchingTimeOn) { state = 3; } } else if (state == 3) { - if (avgCurrent > onCurrent) + if (avgCurrent > onCurrent) state = 2; else if (sim.t-lastTransition > switchingTimeOff) { state = 0; @@ -319,8 +319,38 @@ else if (sim.t-lastTransition > switchingTimeOff) { switchPosition = 0; } } - - if (oldSwitchPosition != switchPosition) + + // compute fractional position (0=deenergized, 1=energized) for + // smooth contact transition. for latching relays, the direction + // depends on the current switchPosition and contacts hold + // position during de-energize. + double elapsed = sim.t - lastTransition; + if (type == TYPE_LATCHING) { + if (state == 1 && switchingTimeOn > 0) { + // latching relay energizing: transition from switchPosition + // toward (1-switchPosition) + double frac = Math.min(elapsed / switchingTimeOn, 1); + if (switchPosition == 1) + d_position = 1 - frac; + else + d_position = frac; + } else { + // latching relay holds position in all other states + d_position = switchPosition; + } + } else { + if (state == 1 && switchingTimeOn > 0) { + d_position = Math.min(elapsed / switchingTimeOn, 1); + } else if (state == 3 && switchingTimeOff > 0) { + d_position = Math.max(1 - elapsed / switchingTimeOff, 0); + } else if (state == 2) { + d_position = 1; + } else { + d_position = 0; + } + } + + if (oldSwitchPosition != switchPosition || state == 1 || state == 3) setSwitchPositions(); } @@ -334,7 +364,7 @@ void setSwitchPositions() { if (o instanceof RelayContactElm) { RelayContactElm s2 = (RelayContactElm) o; if (s2.label.equals(label)) - s2.setPosition(1-switchPosition, type); + s2.setPosition(1-switchPosition, d_position, type); } } } diff --git a/src/com/lushprojects/circuitjs1/client/RelayContactElm.java b/src/com/lushprojects/circuitjs1/client/RelayContactElm.java index 9d121dd57..0a4e74c72 100644 --- a/src/com/lushprojects/circuitjs1/client/RelayContactElm.java +++ b/src/com/lushprojects/circuitjs1/client/RelayContactElm.java @@ -41,9 +41,10 @@ class RelayContactElm extends CircuitElm { final int FLAG_IEC = 4; int type; - // fractional position, between 0 and 1 inclusive -// double d_position; - + // fractional position of contact, between 0 and 1 inclusive + // 0 = fully closed (r_on), 1 = fully open (r_off) + double d_position; + // integer position, can be 0 (off), 1 (on), 2 (in between) int i_position; @@ -103,7 +104,7 @@ void draw(Graphics g) { drawThickLine(g, swposts[i], swpoles[i]); } - interpPoint(swpoles[1], swpoles[2], ptSwitch, i_position); + interpPoint(swpoles[1], swpoles[2], ptSwitch, d_position); //setVoltageColor(g, volts[nSwitch0]); g.setColor(Color.lightGray); drawThickLine(g, swpoles[0], ptSwitch); @@ -120,8 +121,8 @@ void draw(Graphics g) { if (useIECSymbol() && (type == RelayCoilElm.TYPE_ON_DELAY || type == RelayCoilElm.TYPE_OFF_DELAY)) { g.setColor(Color.lightGray); - interpPoint(lead1, lead2, extraPoints[0], .5-2/32., i_position == 1 ? openhs/2 : 0); - interpPoint(lead1, lead2, extraPoints[1], .5+2/32., i_position == 1 ? openhs/2 : 0); + interpPoint(lead1, lead2, extraPoints[0], .5-2/32., d_position * openhs/2); + interpPoint(lead1, lead2, extraPoints[1], .5+2/32., d_position * openhs/2); g.drawLine(extraPoints[0], extraPoints[2]); g.drawLine(extraPoints[1], extraPoints[3]); g.context.beginPath(); @@ -139,9 +140,9 @@ void draw(Graphics g) { switchCurCount = updateDotCount(switchCurrent, switchCurCount); drawDots(g, swposts[0], swpoles[0], switchCurCount); - - if (i_position == 0) - drawDots(g, swpoles[i_position+1], swposts[i_position+1], switchCurCount); + + if (d_position < .5) + drawDots(g, swpoles[1], swposts[1], switchCurCount); drawPosts(g); setBbox(point1, point2, openhs); @@ -150,7 +151,7 @@ void draw(Graphics g) { double getCurrentIntoNode(int n) { if (n == 0) return -switchCurrent; - if (n == 1+i_position) + if (n == 1) return switchCurrent; return 0; } @@ -185,9 +186,16 @@ void setPoints() { } } - public void setPosition(int i_position_, int type_) { + public void setPosition(int i_position_, double coil_d_position, int type_) { i_position = (isNormallyClosed()) ? (1-i_position_) : i_position_; type = type_; + // compute contact fractional position (0 = closed/r_on, 1 = open/r_off) + // NO contact closes as coil energizes (d goes 0->1), so contact_d = 1 - coil_d + // NC contact opens as coil energizes (d goes 0->1), so contact_d = coil_d + if (isNormallyClosed()) + d_position = coil_d_position; + else + d_position = 1 - coil_d_position; } boolean isNormallyClosed() { return (flags & FLAG_NORMALLY_CLOSED) != 0; } @@ -200,6 +208,8 @@ void reset() { super.reset(); switchCurrent = switchCurCount = 0; i_position = 0; + // NO contacts are open at rest (d_position=1), NC contacts are closed (d_position=0) + d_position = isNormallyClosed() ? 0 : 1; // preserve onState because if we don't, Relay Flip-Flop gets left in a weird state on reset. // onState = false; @@ -214,24 +224,24 @@ void stamp() { boolean nonLinear() { return true; } void doStep() { - sim.stampResistor(nodes[nSwitch0], nodes[nSwitch1], i_position == 0 ? r_on : r_off); + // smoothly interpolate resistance based on fractional contact position + // d_position=0 means fully closed (r_on), d_position=1 means fully open (r_off) + double resistance = r_on + (r_off - r_on) * d_position; + sim.stampResistor(nodes[nSwitch0], nodes[nSwitch1], resistance); } void calculateCurrent() { - // actually this isn't correct, since there is a small amount - // of current through the switch when off - if (i_position == 1) - switchCurrent = 0; - else - switchCurrent = (volts[nSwitch0]-volts[nSwitch1+i_position])/r_on; + double resistance = r_on + (r_off - r_on) * d_position; + switchCurrent = (volts[nSwitch0]-volts[nSwitch1]) / resistance; } String getElmType() { return "relay"; } void getInfo(String arr[]) { - arr[0] = Locale.LS("relay"); - if (i_position == 0) - arr[0] += " (" + Locale.LS("off") + ")"; - else if (i_position == 1) - arr[0] += " (" + Locale.LS("on") + ")"; - int i; + arr[0] = Locale.LS("relay contact"); + if (d_position < .1) + arr[0] += " (" + Locale.LS("closed") + ")"; + else if (d_position > .9) + arr[0] += " (" + Locale.LS("open") + ")"; + else + arr[0] += " (" + Locale.LS("transitioning") + ")"; int ln = 1; arr[ln++] = "I = " + getCurrentDText(switchCurrent); } From 477368d6ba35941145c2faa3ea0db0d608eee6dc Mon Sep 17 00:00:00 2001 From: esaruoho Date: Tue, 3 Mar 2026 13:48:22 +0200 Subject: [PATCH 2/3] Use hard step for relay contact resistance instead of smooth interpolation Based on community feedback from @MatNieuw: real relay contacts have a delay (armature travel time) followed by an abrupt open/close, not a smooth resistance ramp. The smooth interpolation was suppressing realistic inductive voltage spikes (e.g. >500V when de-energizing a relay coil with 7H inductance) and adding unnecessary per-tick computation during the delay period. The switching time delay is preserved (states 1 and 3 still wait for the configured time before changing switchPosition), but d_position is now binary (0 or 1) and resistance snaps between r_on and r_off. setSwitchPositions() is only called when the position actually changes. Co-Authored-By: Claude Opus 4.6 --- .../circuitjs1/client/RelayCoilElm.java | 41 +++++-------------- .../circuitjs1/client/RelayContactElm.java | 25 ++++++----- 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java b/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java index 33bd465f7..bf7f10754 100644 --- a/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java +++ b/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java @@ -36,7 +36,8 @@ class RelayCoilElm extends CircuitElm { double coilCurrent, coilCurCount; double avgCurrent; - // fractional position, between 0 and 1 inclusive + // binary position: 0 = de-energized, 1 = energized + // changes as hard step after switching delay expires double d_position; // integer position, can be 0 (off), 1 (on), 2 (in between) @@ -320,37 +321,15 @@ else if (sim.t-lastTransition > switchingTimeOff) { } } - // compute fractional position (0=deenergized, 1=energized) for - // smooth contact transition. for latching relays, the direction - // depends on the current switchPosition and contacts hold - // position during de-energize. - double elapsed = sim.t - lastTransition; - if (type == TYPE_LATCHING) { - if (state == 1 && switchingTimeOn > 0) { - // latching relay energizing: transition from switchPosition - // toward (1-switchPosition) - double frac = Math.min(elapsed / switchingTimeOn, 1); - if (switchPosition == 1) - d_position = 1 - frac; - else - d_position = frac; - } else { - // latching relay holds position in all other states - d_position = switchPosition; - } - } else { - if (state == 1 && switchingTimeOn > 0) { - d_position = Math.min(elapsed / switchingTimeOn, 1); - } else if (state == 3 && switchingTimeOff > 0) { - d_position = Math.max(1 - elapsed / switchingTimeOff, 0); - } else if (state == 2) { - d_position = 1; - } else { - d_position = 0; - } - } + // d_position tracks the binary contact state as a hard step. + // During the switching delay (states 1 and 3), d_position stays + // at its old value. It snaps to the new value only when the + // delay expires and switchPosition actually changes. This + // produces realistic inductive voltage spikes when relay contacts + // open, rather than suppressing them with smooth interpolation. + d_position = switchPosition; - if (oldSwitchPosition != switchPosition || state == 1 || state == 3) + if (oldSwitchPosition != switchPosition) setSwitchPositions(); } diff --git a/src/com/lushprojects/circuitjs1/client/RelayContactElm.java b/src/com/lushprojects/circuitjs1/client/RelayContactElm.java index 0a4e74c72..f92fb7519 100644 --- a/src/com/lushprojects/circuitjs1/client/RelayContactElm.java +++ b/src/com/lushprojects/circuitjs1/client/RelayContactElm.java @@ -41,8 +41,8 @@ class RelayContactElm extends CircuitElm { final int FLAG_IEC = 4; int type; - // fractional position of contact, between 0 and 1 inclusive - // 0 = fully closed (r_on), 1 = fully open (r_off) + // binary contact position: 0 = closed (r_on), 1 = open (r_off) + // changes as a hard step after the relay's switching delay expires double d_position; // integer position, can be 0 (off), 1 (on), 2 (in between) @@ -189,9 +189,10 @@ void setPoints() { public void setPosition(int i_position_, double coil_d_position, int type_) { i_position = (isNormallyClosed()) ? (1-i_position_) : i_position_; type = type_; - // compute contact fractional position (0 = closed/r_on, 1 = open/r_off) - // NO contact closes as coil energizes (d goes 0->1), so contact_d = 1 - coil_d - // NC contact opens as coil energizes (d goes 0->1), so contact_d = coil_d + // compute binary contact position (0 = closed/r_on, 1 = open/r_off) + // coil_d_position is binary (0 or 1), snaps after switching delay. + // NO contact: closed when energized (coil_d=1 -> contact_d=0) + // NC contact: open when energized (coil_d=1 -> contact_d=1) if (isNormallyClosed()) d_position = coil_d_position; else @@ -224,24 +225,22 @@ void stamp() { boolean nonLinear() { return true; } void doStep() { - // smoothly interpolate resistance based on fractional contact position - // d_position=0 means fully closed (r_on), d_position=1 means fully open (r_off) - double resistance = r_on + (r_off - r_on) * d_position; + // d_position is binary: 0 = closed (r_on), 1 = open (r_off). + // Hard step produces realistic inductive voltage spikes. + double resistance = d_position > .5 ? r_off : r_on; sim.stampResistor(nodes[nSwitch0], nodes[nSwitch1], resistance); } void calculateCurrent() { - double resistance = r_on + (r_off - r_on) * d_position; + double resistance = d_position > .5 ? r_off : r_on; switchCurrent = (volts[nSwitch0]-volts[nSwitch1]) / resistance; } String getElmType() { return "relay"; } void getInfo(String arr[]) { arr[0] = Locale.LS("relay contact"); - if (d_position < .1) + if (d_position < .5) arr[0] += " (" + Locale.LS("closed") + ")"; - else if (d_position > .9) - arr[0] += " (" + Locale.LS("open") + ")"; else - arr[0] += " (" + Locale.LS("transitioning") + ")"; + arr[0] += " (" + Locale.LS("open") + ")"; int ln = 1; arr[ln++] = "I = " + getCurrentDText(switchCurrent); } From 907576a4dbd48512c45dda6ada12abcac3a21b61 Mon Sep 17 00:00:00 2001 From: esaruoho Date: Tue, 3 Mar 2026 14:55:14 +0200 Subject: [PATCH 3/3] Add separate release time for asymmetric relay switching Real relays release faster than they engage because the spring force assists the armature return. Add a configurable Release Time parameter (shown for Normal and Latching relay types) so users can model this asymmetry. When Release Time is 0 (default), it falls back to the Switching Time for backwards compatibility. Based on feedback from @MatNieuw with measured HK19F relay behavior. Co-Authored-By: Claude Opus 4.6 --- .../circuitjs1/client/RelayCoilElm.java | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java b/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java index bf7f10754..41d90dbd7 100644 --- a/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java +++ b/src/com/lushprojects/circuitjs1/client/RelayCoilElm.java @@ -47,6 +47,10 @@ class RelayCoilElm extends CircuitElm { // time to switch in seconds double switchingTime; + // release time (0 = same as switchingTime). real relays typically + // release faster than they engage because the spring force assists + // the armature return. + double releaseTime; double switchingTimeOn, switchingTimeOff; double lastTransition; @@ -122,6 +126,8 @@ void dumpXml(Document doc, Element elem) { XMLSerializer.dumpAttr(elem, "cr", coilR); XMLSerializer.dumpAttr(elem, "ofc", offCurrent); XMLSerializer.dumpAttr(elem, "swt", switchingTime); + if (releaseTime > 0) + XMLSerializer.dumpAttr(elem, "rt", releaseTime); XMLSerializer.dumpAttr(elem, "tp", type); } void dumpXmlState(Document doc, Element elem) { @@ -137,6 +143,7 @@ void undumpXml(XMLDeserializer xml) { coilR = xml.parseDoubleAttr("cr", coilR); offCurrent = xml.parseDoubleAttr("ofc", offCurrent); switchingTime = xml.parseDoubleAttr("swt", switchingTime); + releaseTime = xml.parseDoubleAttr("rt", 0); type = xml.parseIntAttr("tp", type); coilCurrent = xml.parseDoubleAttr("ci", coilCurrent); state = xml.parseIntAttr("st", state); @@ -272,6 +279,7 @@ void stamp() { // resistor from internal node to coil post 2 sim.stampResistor(nodes[nCoil3], nodes[nCoil2], coilR); + double effectiveReleaseTime = (releaseTime > 0) ? releaseTime : switchingTime; if (type == TYPE_ON_DELAY) { switchingTimeOn = switchingTime; switchingTimeOff = 0; @@ -279,7 +287,8 @@ void stamp() { switchingTimeOff = switchingTime; switchingTimeOn = 0; } else { - switchingTimeOff = switchingTimeOn = switchingTime; + switchingTimeOn = switchingTime; + switchingTimeOff = effectiveReleaseTime; } setSwitchPositions(); } @@ -390,15 +399,21 @@ public EditInfo getEditInfo(int n) { return new EditInfo("Coil Resistance (ohms)", coilR, 0, 0).setPositive(); if (n == 5) return new EditInfo("Switching Time (s)", switchingTime, 0, 0).setPositive(); - if (n == 6) + if (n == 6 && (type == TYPE_NORMAL || type == TYPE_LATCHING)) + return new EditInfo("Release Time (s)", releaseTime > 0 ? releaseTime : switchingTime, 0, 0).setPositive(); + int labelIdx = (type == TYPE_NORMAL || type == TYPE_LATCHING) ? 7 : 6; + if (n == labelIdx) return new EditInfo("Label (for linking)", label); return null; } public void setEditValue(int n, EditInfo ei) { if (n == 0) { + int oldType = type; type = ei.choice.getSelectedIndex(); setPoints(); + if (oldType != type) + ei.newDialog = true; } if (n == 1 && ei.value > 0) { inductance = ei.value; @@ -412,7 +427,10 @@ public void setEditValue(int n, EditInfo ei) { coilR = ei.value; if (n == 5 && ei.value > 0) switchingTime = ei.value; - if (n == 6) + if (n == 6 && (type == TYPE_NORMAL || type == TYPE_LATCHING) && ei.value > 0) + releaseTime = ei.value; + int labelIdx = (type == TYPE_NORMAL || type == TYPE_LATCHING) ? 7 : 6; + if (n == labelIdx) label = ei.textf.getText(); }