Mikrodev/stcoder-qwen35-9b-ggufFine-tuned from Qwen/Qwen3.5-9B apache-2.0GGUF · Ollama / llama.cpp

stcoder-qwen35-9b

stcoder-qwen35-9b is a chat-only fine-tune of Qwen3.5-9B that writes Mikrodev LogicStudio Structured Text; it is the only member of the line built on a reasoning base, so it thinks before it answers — which buys clean, reference-shaped code but costs roughly four times the tokens of its siblings and cost it one non-delivery in fifteen.

This is a fine-tune for the Structured Text coding rules of Advance Logic Builder (ALB), the IEC 61131-3 engineering environment developed by Mikrodev and shipped as Mikrodev LogicStudio. It follows Mikrodev's ST dialect, not generic IEC 61131-3 and not another vendor's conventions.

Base model
Qwen3.5-9BQwen/Qwen3.5-9B
Builds
3Q4_K_M / Q6_K / Q8_0
Smallest
5.4 GiBQ4_K_M
Recommended
Q8_09.11 GiB
Dialect-clean
93.3%base: 6.7%
Tool calling
Not supportedchat only
1

Which build should you download?

Start from the graphics memory you have. Every build below is the same fine-tune; they differ only in how heavily the weights are compressed.

11.1 GiB free VRAM
Q8_0qwen35_9b-tc.q8_0.gguf · 9.11 GiB
recommended — the build this study ran
9.0 GiB free VRAM
Q6_Kqwen35_9b-tc.q6_k.gguf · 7.04 GiB
fine
7.4 GiB free VRAM
Q4_K_Mqwen35_9b-tc.q4_k_m.gguf · 5.38 GiB
not recommended

Short answer

Download Q8_0 (qwen35_9b-tc.q8_0.gguf, 9.11 GiB). Q8_0 is the smallest build with no practically measurable loss against the trained weights - it is the precision we recommend for real work. Free VRAM needed: about 11.1 GiB — the file plus the 8192-token context and runtime.

Why not Q4_K_M

Q4_K_M halves the bits per weight again. The only capability we measured across quantisations degraded sharply - tool-call composite 0.816 at Q8_0 against 0.642 at Q4_K_M, measured on the Qwen3.5-9B family, the only one of the four where tool-calling worked at all - and low-bit quantisation is a known source of drift and hallucination on long or unusual requests. Our 15-prompt code study never ran at Q4, so we have no measured code-quality figure for it: treat this as a precaution, not a measured code-quality gap.

Also published for this model. The LoRA adapter it was trained as (lora_adapter/ in the same repository) so you can merge it at any precision or continue training from it, and the f16 merged weights (f16 is the merged fine-tune at full precision: the reference build, for evaluation or for producing your own quantisations.). Ready-to-run Ollama Modelfiles ship next to every build: correct chat template, stop tokens, num_ctx 8192 — the sequence length this model was fine-tuned at — and the system prompt.

FileQuantisationSizeFit on 16 GBNote
qwen35_9b-tc.q4_k_m.ggufQ4_K_M5.38 GiBsolid
qwen35_9b-tc.q6_k.ggufQ6_K7.04 GiBsolid
qwen35_9b-tc.q8_0.ggufQ8_09.11 GiBsolidtested in this study
2

Is this the right model for you?

Read this first: tool calling was trained and evaluated for this line and then dropped as a product decision. This build is published and supported as a chat-only model. This family is in fact the one that did learn the skill (tool-call composite 0.816 at Q8_0, 0.805 at Q6_K, collapsing to 0.642 at Q4_K_M) while stcoder-qwen25-7b and stcoder-qwen25-14b scored 0.02-0.06 and stcoder-gemma4-12b scored 0.00. Because it was not usable across the line it is not part of this release and is not supported here. If you have not already decided, take stcoder-qwen25-7b at Q6_K (5.82 GiB). That is the line default: 5.8 s per answer, the smallest files, 100% delivery, and the best ChrF at full delivery in this study (38.5 over 15 of 15), with composite within noise of everything else. Pick stcoder-qwen35-9b instead only if you specifically want the reasoning family and can pay for it in latency. On the minimal-prompt condition it scored R24 100.0 with an empty violation table on every delivered reply, 93.3% clean, and composite 0.845 over its 14 delivered replies (0.835 if the missing reply is counted as zero, which puts it behind the 7B's 0.844 and the 14B's 0.842). Its ChrF is 40.9 over those 14 delivered replies — the highest delivered-only figure in the set — but 38.2 over all 15, below the 7B's 38.5; the lead exists only after excluding its own empty reply, so quote the denominator whenever you quote the number. Every reply carries a hidden thinking block (100% of replies in all four arms), averaging 1227 output tokens and 17.2 s per answer at the minimal prompt against 340 tokens and 5.8 s for the 7B. It is the slowest of the four per answer; the 14B is the slowest per token (about 50 tok/s). Accept also that it was the only fine-tune in the study that ever returned no code at all (14 of 15 at the minimal prompt, 15 of 15 with the full rule book).

Table — the four models on identical measurements
Same 15 requests, realistic prompt, greedy decoding. ChrF is averaged over delivered replies so the denominator is the same for every model.
ModelCleanDeliveredChrFComposite Out tokPer replyRecommended build
stcoder-qwen25-7b
7B · default pick, fastest per answer
93.3%15/1538.50.8443405.8sQ8_0 · 7.54 GiB
stcoder-qwen25-14b
14B · only one 100% clean in both conditions
100.0%15/1534.90.8422888.6sQ8_0 · 14.62 GiB
stcoder-qwen35-9b (this model)
9B · reasoning family, highest ChrF when it answers
93.3%14/1540.90.845122717.2sQ8_0 · 9.11 GiB
stcoder-gemma4-12b
12B · non-Qwen lineage, weakest fine-tune case
86.7%15/1535.90.834018.5sQ8_0 · 11.80 GiB

How to read this. The 14B has the highest clean rate (100%). The 9B has the highest ChrF among delivered replies (40.9) but delivered on only 14 of 15, so at full delivery the 7B leads (38.5). Composites for the 9B, 7B and 14B (0.845 / 0.844 / 0.842) are within noise of each other. Sizes are GiB throughout. If you are undecided, take stcoder-qwen25-7b at Q8_0.

Read this before you start

  • Chat only — no tool calling. You describe what you need and the model writes Structured Text. Tool calling was trained and evaluated for this line: the Qwen3.5-9B family did learn it (tool-call composite 0.816 at Q8_0, falling to 0.642 at Q4_K_M) while the code-focused families scored 0.02–0.06 and the Gemma family exactly 0.00. Because it was not usable across the line, tool calling was dropped as a product decision and these builds are published and supported as chat models. Do not build an agent on them.
  • Always compile before deployment. The model produces dialect-correct code, which is not the same as correct control logic. Compile it in LogicStudio and review the logic.
  • It is a small local model. Expect to iterate on a prompt, not to receive a finished project.
3

Set-up, step by step

  1. Install Ollama

    Any recent release. Verify it responds before continuing.

    ollama --version
  2. Download one GGUF file

    Q6_K is the recommendation for 12 GB and up; Q4_K_M for 8 GB; Q8_0 is the quant the published study numbers were measured on. You need one file only. The "-tc" in the filename is a build-pipeline tag, not a tool-calling build - these are chat-only.

    hf download Mikrodev/stcoder-qwen35-9b-gguf qwen35_9b-tc.q6_k.gguf --local-dir .
  3. Write a Modelfile next to the GGUF

    Context window matters more for this model than for its siblings because it reasons before it answers. 8192 is the budget used in the study; 16384 removes the empty-reply risk. Greedy decoding (temperature 0) is what was measured.

    cat > Modelfile <<'EOF'
    FROM ./qwen35_9b-tc.q6_k.gguf
    PARAMETER num_ctx 8192
    # the published numbers were measured with num_ctx 16384 / num_predict 8192
    # this family reasons before answering and the thinking is drawn from the same budget, so do not set num_predict lower
    PARAMETER num_predict 8192
    PARAMETER temperature 0
    PARAMETER top_p 1
    SYSTEM """You are a PLC programming assistant for the Mikrodev LogicStudio IDE. You write IEC 61131-3 Structured Text in the Mikrodev dialect: no POU wrappers, no TIME literals (use INT milliseconds), positional function-block calls only, no OOP, no pointers, no global or retained variables, no STRING types, no hex literals, no cast or selection functions. Declare variables in VAR / VAR_INPUT / VAR_OUTPUT / VAR CONSTANT blocks and use (* *) or // comments."""
    EOF
  4. Create the model in Ollama

    Takes a few seconds; the GGUF is copied into the Ollama store.

    ollama create stcoder-qwen35-9b:q6 -f Modelfile
  5. Ask it for something small

    The first reply includes a hidden reasoning pass, so expect a pause of several seconds before code appears. That is normal for this model and is why it averaged 17.2 s per answer in the study.

    ollama run stcoder-qwen35-9b:q6 "Chiller circulation pump has to keep running 45 seconds after the compressor stops. E-stop drops both at once. Give me the ST."
  6. Reproduce the published numbers, if you want to

    The 240-generation head-to-head used these exact tags. The base arms ran at the Ollama library default, whose quantisation was not recorded in the run data.

    # fine-tunes: stcoder_qwen35-9b:q8  stcoder_qwen25-7b:q8  stcoder_qwen25-14b:q6  stcoder_gemma4-12b:q6
    # bases:      qwen3.5:9b            qwen2.5-coder:7b      qwen2.5-coder:14b      gemma4:12b
  7. Paste into LogicStudio and compile

    Always compile before deployment. The scoring behind this release checks dialect compliance, not whether the logic is correct - and this model's replies also need a check for doubled comment delimiters, which the scorer does not catch.

4

What to ask it first

These are ordinary single-purpose requests, the kind this model handles well. Type them as they are.

Car-wash sump pump. Start pushbutton on DI1, NC stop on DI2, pump overload aux on DI3, contactor on RO1. Write me a start/stop seal-in. The start button is bouncy so it has to be held half a second before the pump picks up, and an overload trip has to latch the pump out until someone resets it.
A body-only block with VAR_INPUT / VAR_OUTPUT, a TON called positionally as tStartHold(bStartBtn, START_HOLD_MS) with START_HOLD_MS : INT := 500 in a VAR CONSTANT block, an RS latch for the contactor and a separate lockout flag.
Fresh scenario, not one of the 15 study prompts. Seal-in logic was one of the model's stronger categories in the study, and it reaches for RS and R_TRIG without being told to. Check the reply for doubled comment delimiters - the study's seal-in row is one of the two minimal-prompt replies that had them.
Chiller circulation pump has to keep running 45 seconds after the compressor stops so the loop clears. E-stop cuts compressor and pump straight away. Give me a purge-in-progress flag for the HMI too.
A timer with PURGE_TIME_MS : INT := 45000, the pump driven from bCompressorRun OR the timer output, and an E-stop override at the end that forces both outputs off.
Fresh scenario, not one of the 15 study prompts. Off-delay behaviour scored clean in the study, but check the retrigger condition yourself: on the equivalent study row the model latched on the compressor's falling edge rather than using a TOF-style pattern.
Ultrasonic flow meter on a 4-20 mA input. Raw word is an integer, 0 counts = 0 m3/h and 32767 counts = 250 m3/h. Scale it, clamp the result to 0-250, and raise a wire-break flag below 1000 counts. There is an enable bit; when it is off the output reads zero.
INT counts into REAL maths with named constants, no INT_TO_REAL call, no MIN/MAX, and the clamp written out as IF/ELSIF.
Fresh scenario, not one of the 15 study prompts. Scaling was one of the model's cleanest categories (the study's scaling row scored ChrF 42.6 with zero violations). The base model reached for a cast function and MIN/MAX on that row; the fine-tune did not.
Operators type a brine chiller setpoint on the HMI. Pull anything below -8.0 or above 2.0 degC back to the nearest limit and raise an out-of-range warning. The setpoint going to the controller must not move more than 0.5 degC per scan, so ramp it.
MIN_TEMP / MAX_TEMP / RAMP_STEP as VAR CONSTANT REALs, a clamp, and a three-branch ramp toward the clamped target.
Fresh scenario, not one of the 15 study prompts. Clamp-and-ramp was the model's highest-scoring study row (ChrF 51.2). Read the acknowledge handling before you keep it - the warning latch was the weak part of that answer.
A photo eye pulses once per carton leaving the label applicator. I need a lifetime total for maintenance records and a shift total the supervisor clears with bShiftReset. Count on the rising edge.
R_TRIG on the photo eye, DINT for the lifetime total, INT for the shift total, and a reset that only zeroes the shift counter.
Fresh scenario, not one of the 15 study prompts. Expect compact code and no mention of persistence: RETAIN is forbidden in this dialect, so the model will not solve power-cycle survival for you and will not say so either. The study's counter row was among its lower ChrF results (33.4).
We poll a 16-bit soft-starter status word over Modbus. Bit 0 ready, bit 2 ramping, bit 4 fault, bit 6 bypassed, bits 12-15 are a 4-bit trip code. Break it out into booleans, give me the trip code as a plain integer, and one starter-healthy flag. If the poll is invalid do not trust any of it.
Decimal mask constants rather than 16#0001, bitwise AND comparisons against 0, a shift for the trip nibble, and an ELSE branch that forces safe values when the poll is invalid.
Fresh scenario, not one of the 15 study prompts. Bit-masking was one of the model's best categories (the study's status-word row scored ChrF 49.8, zero violations), but check the mask values and shift amount against your drive manual - on that row the nibble mask it chose did not match the bit range asked for.
AGV crossing strobe. While the crossing-active flag is set the strobe should flash continuously, lit for 300 ms then dark for 1.2 seconds, over and over. When the flag clears the strobe stays off. No hardware flasher relay in this panel.
Two timers or one timer plus a phase flag, ON_MS and OFF_MS as INT constants, and an output that toggles - not an output tied straight to a single timer's Q.
Fresh scenario, not one of the 15 study prompts, and the one to try if you want to see the model's main failure mode. On the study's blink row it declared FLASH_OFF_MS : INT := 1500, never used it, and drove the lamp from a single TON output, so the lamp would latch on instead of blinking - R24 100.0, logic wrong. Verify the off phase exists before you keep the block.
In the block you just wrote, why did you use an RS latch instead of a plain IF assignment, and what happens if start and stop are true in the same scan?
A short prose explanation of reset dominance and the scan-order consequence, with no code unless you ask for it.
Fresh scenario, not one of the 15 study prompts. This model keeps a reasoning pass on every turn and handles follow-ups in the same session well. Its study code replies carried no surrounding essay (the prose-opening flag fired on 0% of fine-tune replies), so ask for explanation explicitly when you want it.
5

What fine-tuning changed, measured

The same 15 requests were sent to the stock base model (qwen3.5:9b) and to this fine-tune, twice: once with a short realistic system prompt that lists no dialect rules, and once with the entire rule book pasted in. Greedy decoding, fixed seed, identical prompts.

Figure 1 — dialect-clean replies out of 15
Clean means: code was delivered and it broke no dialect rule
basethis fine-tune
No rules in promptrealistic use
6.7%
93.3%
Full rule book in promptcharitable to base
26.7%
100.0%
Figure 2 — per-prompt outcome, no rules in prompt
One tick per test prompt, same order in both rows
cleanrule violation no code delivered
Base
Fine-tune
MeasureBaseThis fine-tuneBase + full rulesFine-tune + full rules
Dialect-clean replies6.7%93.3%26.7%100.0%
Delivered code at all80.0%93.3%80.0%100.0%
R24 rule compliance93.02100.096.57100.0
ChrF vs reference code28.940.934.137.6
Output tokens (mean · median)6345 · 69521227 · 7932902 · 1521554 · 488
Time per reply54.5s17.2s26.7s9.0s
Throughput123.2 t/s86.3 t/s120.5 t/s84.0 t/s
Opened with prose, not code80.0%0.0%66.7%0.0%

On throughput: the fine-tune is served here at higher precision than the stock base model (Ollama's default build is more heavily compressed), so it generates more slowly per token. Quantisation was not matched in this comparison — see the limitations.

6

Which dialect rules the base model breaks

Counted over replies that actually contained code. The upper bar is the realistic condition; the lower, lighter bar is with the full rule book in the system prompt — the difference shows which habits an instruction can fix and which it cannot.

Figure 3 — base-model rule violations, qwen3.5:9b — all 15 rules it broke
bar length = share of delivered replies that broke the rule, full width = 100%
no_VAR_GLOBALminimal · full rules
5/12
2/12
no_named_paramminimal · full rules
2/12
4/12
no_pou_fbminimal · full rules
4/12
1/12
no_pou_programminimal · full rules
4/12
0/12
no_single_quoteminimal · full rules
2/12
0/12
no_c_commentminimal · full rules
2/12
0/12
no_lowercase_kwminimal · full rules
1/12
1/12
no_cast_functionminimal · full rules
1/12
1/12
no_double_quoteminimal · full rules
2/12
0/12
no_var_retainminimal · full rules
1/12
0/12
no_select_fnminimal · full rules
1/12
0/12
no_TIME_literalminimal · full rules
1/12
0/12
no_direct_addressminimal · full rules
0/12
1/12
no_based_literalminimal · full rules
0/12
1/12
no_STRING_typeminimal · full rules
1/12
0/12

For comparison, this fine-tune's violations across its 14 delivered replies in the same condition: none.

7

The same request, side by side

Highlighted tokens are the ones that decide whether the code is accepted by the IDE. Both columns are verbatim model output.

Three packaging machines in one array (minimal prompt): R24 86.76 to 100.0, 5671 tokens to 876

machine_data_grouping · data_memory · no dialect rules in the system prompt

The request: I have three identical packaging machines in one project. For each machine I track a temperature setpoint, the actual temperature, the belt speed, a cycle counter and a state code. I would like each machine's values kept together so I can run the same monitoring logic over all three in a loop instead of copy-pasting the same block three times. Logic per machine: if it is in the running state and t

Base · qwen3.5:9b R24 86.76 · 5671 tok
Here is the Structured Text solution designed for Mikrodev LogicStudio (IEC 61131-3). 

This approach uses a **Type Definition** to encapsulate machine data, an **Array of Structures** to handle all three machines simultaneously in loops, and global variables to manage state persistence across scan cycles.

```st
(* 
   Project: Packaging Machine Monitoring System
   Description: Monitors 3 identical packaging machines using arrays for scalability.
               Handles Temperature deviation, Belt Speed, Cycle Counting, and EStop logic.
*)

TYPE T_MachineData :
    (* Configuration *)
    SetpointTemp     : REAL;      // Target temperature (e.g., in Celsius)
    ActualTemp       : REAL;      // Current sensor reading
    
    (* Process Parameters *)
    BeltSpeed        : REAL;      // Speed of the belt (e.g., m/s or RPM)
    
    (* Status & Control *)
    IsRunning        : BOOL;      // True if machine is in active running state
    CycleCounter     : INT := 0;  // Counts completed cycles
    
    (* Faulting State *)
    MachineFaulted   : BOOL;      // Indicates current fault status for this specific machine
END_TYPE

(* Global Variables - Persist across scan cycles *)
VAR_GLOBAL
    Machines         : ARRAY[1..3] OF T_MachineData; 
    PrevCycleDone    : ARRAY[1..3] OF BOOL := (FALSE, FALSE, FALSE); // For rising edge detection
    
    (* External Inputs/Outputs *)
    EStopInput       : BOOL;      // Emergency Stop Input from hardware/controller
    TotalFaultCount 
no_VAR_GLOBALno_double_quoteno_lowercase_kwno_single_quote
STCoder fine-tune R24 100.0 · 876 tok
```iecst
(* Machine monitoring for 3 identical packaging machines *)
(* Each machine has the same structure and logic applied via array *)

TYPE MACHINE :
  nState : INT := 0;          (* 0=STOPPED, 1=RUNNING, 2=FAULTED *)
  rTempSetpoint : REAL;
  rTempActual : REAL;
  rBeltSpeed : REAL;
  nCycleCount : DINT;
  bEStop : BOOL;
  bFaulted : BOOL;
END_TYPE

VAR_INPUT
  arrTempSetpoint : ARRAY[0..2] OF REAL;
  arrTempActual : ARRAY[0..2] OF REAL;
  arrBeltSpeed : ARRAY[0..2] OF REAL;
  arrCycleDone : ARRAY[0..2] OF BOOL;
  bEStop : BOOL;
END_VAR

VAR_OUTPUT
  arrMachine : ARRAY[0..2] OF MACHINE;
  nFaultedCount : INT;
END_VAR

VAR
  i : INT;
  rCycleDoneEdge : R_TRIG;
END_VAR

(* Initialize machines *)
FOR i := 0 TO 2 DO
  arrMachine[i].nState := 0;
  arrMachine[i].nCycleCount := 0;
  arrMachine[i].bFaulted := FALSE;
END_FOR;

(* Process each machine with identical logic *)
FOR i := 0 TO 2 DO
  arrMachine[i].rTempSetpoint := arrTempSetpoint[i];
  arrMachine[i].rTempActual := arrTempActual[i];
  arrMachine[i].rBeltSpeed := arrBeltSpeed[i];
  
  (* Temperature deviation fault *)
  IF arrMachine[i].nState = 1 THEN
    IF (arrMachine[i].rTempActual - arrMachine[i].rTempSetpoint) > 5.0
       OR (arrMachine[i].rTempSetpoint - arrMachine[i].rTempActual) > 5.0 THEN
      arrMachine[i].bFaulted := TRUE;
      arrMachine[i].nState := 2;
    END_IF;
  END_IF;
  
  (* Belt speed fault *)
  IF arrMachine[i].nState = 1 AND arrMachine[i].rBeltSpeed < 10.0 THEN
    arrMachine[i].bFaulted := TR
no violations

The base answer opens with two paragraphs of prose, then builds exactly the structure the Mikrodev runtime rejects. It declares a user type with TYPE T_MachineData :, parks the machine array in a VAR_GLOBAL block so it "persists across scan cycles", annotates the fields with C-style // Current sensor reading comments, and wraps the logic in a BEGIN block - a Pascal-ism that is not ST at all. R24 put it at 86.76 after 5671 tokens, flagging no_VAR_GLOBAL, no_double_quote, no_single_quote and no_lowercase_kw; worth knowing that the lowercase-keyword hits landed on English words inside the reply's comment prose rather than on executable statements, while the VAR_GLOBAL, user-type and BEGIN problems are real. The fine-tune spent 876 tokens and scored 100.0. It exposes the per-machine values as plain 1-D arrays in VAR_INPUT, keeps the state code as an INT with an inline (* 0=STOPPED, 1=RUNNING, 2=FAULTED *) comment instead of an enum, and detects the cycle pulse with a declared R_TRIG. One honest caveat, visible in the marks: the fine-tune still emitted a TYPE MACHINE : ... END_TYPE block of its own for the output array. R24 did not flag it and the row counted as clean, but the dialect has no user-defined types, so that declaration has to go before the block will compile.

Red spans in the base panel are the exact substrings the rule checker matched. The fine-tune panel is shown unmarked because it broke no rule on this prompt — read it against the base, line for line.

Reusable pump block (minimal prompt): R24 88.24 to 100.0, and the base hit the 8192-token wall

reusable_pump_block · program_structure · no dialect rules in the system prompt

The request: We have three identical booster pumps on a skid and I am tired of copy-pasting the same logic three times. Give me one reusable pump control block that I can instantiate once per pump. Per instance: run request, motor thermal overload contact, dry-run (low suction) switch, fault reset. Outputs: motor run command, fault flag, and a fault code for the HMI so the operator can see what tripped. Behavi

Base · qwen3.5:9b R24 88.24 · 8192 tok
Here is a reusable **Pump Control Function Block** written in Structured Text (ST), compatible with the Mikrodev LogicStudio IDE environment following IEC 61131-3 standards.

### PumpControl FB Definition

This block encapsulates all logic, timers, and fault handling into one instance per pump. It handles start delays, dry-run protection windows, overload latching, restart lockouts, and HMI reporting.

```st
FUNCTION_BLOCK PumpControl :
VAR_INPUT
    RunRequest   : BOOL;      // Operator request to run the pump (e.g., from level switch)
    OverloadIn   : BOOL;      // TRUE = Thermal Overload Relay Tripped / Fault Active
    DryRunSwitch : BOOL;      // TRUE = Low Suction Pressure Detected (Dry-run condition)
    ResetButton  : BOOL;      // Momentary or Level signal to clear fault latch and reset timers
END_VAR

VAR_OUTPUT
    MotorCmd     : BOOL;      // Output command for the motor starter/drive
    FaultFlag    : BOOL;      // General alarm flag for HMI (TRUE if any active fault)
    FaultCode    : INT;       // Specific code: 0=OK, 1=Overload, 2=Dry Run
END_VAR

VAR
    StartDelayTimer   : TON;     // Handles the 2s start delay after request
    SpinUpTimer       : TON;     // Waits for motor to stabilize (5s) before checking dry-run
    CooldownTimer     : TON;     // Enforces 10s restart lockout after pump stops
    
    MotorRunning      : BOOL := FALSE; // Internal flag to track if we are currently running
END_VAR

// --- Logic Implementation ---

// 1. Reset Fault L
no_TIME_literalno_named_paramno_pou_fb
STCoder fine-tune R24 100.0 · 1139 tok
```iecst
(* (*
  Reusable booster pump control block.
  Instantiate once per pump to avoid copy-paste.
*) *)
VAR_INPUT
    bRunReq       : BOOL;   (* (* Operator run request *) *)
    bOverload     : BOOL;   (* (* Motor thermal overload contact *) *)
    bDryRun       : BOOL;   (* (* Low suction / dry-run switch *) *)
    bFaultReset   : BOOL;   (* (* Fault reset button *) *)
    bStartPulse   : BOOL;   (* (* Start pulse for 2s delay *) *)
    bStopPulse    : BOOL;   (* (* Stop pulse for 10s off time *) *)
END_VAR
VAR_OUTPUT
    bMotorRun     : BOOL;   (* (* Motor run command *) *)
    bFault        : BOOL;   (* (* Latched fault flag *) *)
    nFaultCode    : INT;    (* (* Fault code for HMI *) *)
END_VAR
VAR
    tStartDelay   : TON;   (* (* 2s start delay *) *)
    tDryRunDelay  : TON;   (* (* 5s dry-run evaluation delay *) *)
    tOffDelay     : TON;   (* (* 10s minimum off time *) *)
    bStartDone    : BOOL;  (* (* Start delay elapsed *) *)
    bDryRunEval   : BOOL;  (* (* Dry-run evaluation ready *) *)
    bFaultLatched : BOOL;  (* (* Latched fault *) *)
END_VAR
VAR CONSTANT
    START_DELAY_MS : INT := 2000;
    DRYRUN_DELAY_MS: INT := 5000;
    OFF_DELAY_MS   : INT := 10000;
END_VAR

(* (* Start delay *) *)
tStartDelay(bStartPulse, START_DELAY_MS);
bStartDone := tStartDelay.Q;

(* (* Dry-run evaluation delay *) *)
tDryRunDelay(bMotorRun, DRYRUN_DELAY_MS);
bDryRunEval := tDryRunDelay.Q;

(* (* Off delay *) *)
tOffDelay(NOT bMotorRun, OFF_DELAY_MS);

(* (* Fault logic *) 
no violations

This is the clearest case of the base fighting the dialect. It opens with FUNCTION_BLOCK PumpControl : - the IDE owns that shell, code is body-only - declares its timers with C-style // Handles the 2s start delay after request comments, and then, further down the reply, calls every one of them the CODESYS way: the scorer recorded StartDelayTimer(IN:=FALSE, PT := T#2S), CooldownTimer(IN:=TRUE) and SpinUpTimer(IN:=TRUE) under no_named_param and no_TIME_literal, where Mikrodev wants positional arguments and INT milliseconds. It also tried to assign to its own input with OverloadIn := FALSE;, argued with itself in a comment about whether that was legal, and finally ran out at the full 8192-token budget mid-thought. Score 88.24. The fine-tune produced the whole block in 1139 tokens at 100.0: START_DELAY_MS : INT := 2000 in a VAR CONSTANT block, tStartDelay(bStartPulse, START_DELAY_MS) called positionally, tOffDelay(NOT bMotorRun, OFF_DELAY_MS) for the restart lockout, and the three instances shown separately as asked. The flaw to fix by hand is cosmetic but real, and it is in the marks: this reply doubled its comment delimiters throughout, (* (* 2s start delay *) *), which is not valid ST and which the scorer does not catch.

Red spans in the base panel are the exact substrings the rule checker matched. The fine-tune panel is shown unmarked because it broke no rule on this prompt — read it against the base, line for line.

Drilling station sequence with the full rule book in the prompt: R24 95.59 to 100.0

machine_cycle_full_program · program_structure · entire rule book in the system prompt

The request: Can you write the complete program for our drilling station cycle? One part per cycle: when the guard is closed, a part is present and there are no alarms, pressing Start closes the clamp, waits 1.5 s for clamp pressure to build, then the drill motor starts and the head feeds down until the down limit switch, dwells 3 s at depth, retracts until the up limit, opens the clamp after 0.8 s, and the st

Base · qwen3.5:9b R24 95.59 · 3392 tok
(* Drilling Station Cycle Logic *)
VAR
    (* Inputs *)
    bGuardClosed : BOOL;
    bPartPresent : BOOL;
    btnStart : BOOL;
    swDownLimit : BOOL;
    swUpLimit : BOOL;
    btnEStop : BOOL;
    alrDrillOverload : BOOL;

    (* Outputs *)
    clmClampClosed : BOOL;
    mtrFeedMotor : BOOL;
    mtrRetractMotor : BOOL;
    vlvOpenClamp : BOOL;
    
    (* Timers - clamp pressure build, dwell at depth, open clamp delay *)
    tClampPressure : TON;
    tDwellAtDepth : TON;
    tOpenClampDelay : TON;

    (* State machine for step sequencing *)
    eState : (IDLE, CLAMPING, DRILLING, DWELL, RETRACTING, OPEN_CLAMP);
    
    (* Fault state flag *)
    bFaultActive : BOOL;
END_VAR

(* Safety check: E-stop or overload drops all outputs and sets fault *)
IF btnEStop OR alrDrillOverload THEN
    clmClampClosed := FALSE;
    mtrFeedMotor := FALSE;
    mtrRetractMotor := FALSE;
    vlvOpenClamp := TRUE;  (* Open clamp for safety *)
    bFaultActive := TRUE;
END_IF

(* Reset fault when reset button pressed and no active faults *)
IF btnReset AND NOT (btnEStop OR alrDrillOverload) THEN
    bFaultActive := FALSE;
    clmClampClosed := FALSE;  (* Ensure clamp is open during reset *)
    mtrFeedMotor := FALSE;
    mtrRetractMotor := FALSE;
END_IF

(* Only allow cycle to start if guard closed, part present, no fault, and not already running *)
IF NOT bFaultActive AND btnStart THEN
    CASE eState OF
        IDLE:
            (* Start clamp closing sequence *)
            tClampPressure(IN :
no_named_param
STCoder fine-tune R24 100.0 · 809 tok
```iecst
(* Drilling station cycle with clamp, drill, feed, dwell, retract, and clamp-open sequence *)
(* Safety: E-stop or overload forces fault state; reset button clears fault *)

VAR_INPUT
    bGuardClosed : BOOL;
    bPartPresent : BOOL;
    bStartCycle  : BOOL;
    bEStop       : BOOL;
    bResetBtn    : BOOL;
    bDrillOverload : BOOL;
    bClampClosed : BOOL;
    bDownLimit   : BOOL;
    bUpLimit     : BOOL;
END_VAR
VAR_OUTPUT
    bClampMotor   : BOOL;
    bDrillMotor   : BOOL;
    bFeedMotor    : BOOL;
    bClampOpen    : BOOL;
    bStationFault : BOOL;
    nPartsDone    : INT;
END_VAR
VAR
    nState : INT := 0;
    tClamp : TON;
    tDwell : TON;
    bFault : BOOL;
END_VAR
VAR CONSTANT
    CLAMP_BUILD_MS : INT := 1500;
    DWELL_MS       : INT := 3000;
    CLAMP_OPEN_MS  : INT := 800;
END_VAR

(* Safety interlocks *)
IF bEStop OR bDrillOverload THEN
    bStationFault := TRUE;
    bClampMotor := FALSE;
    bDrillMotor := FALSE;
    bFeedMotor := FALSE;
    bClampOpen := FALSE;
    nState := 0;
    RETURN;
END_IF;

CASE nState OF
    0: (* Idle *)
        bClampMotor := FALSE;
        bDrillMotor := FALSE;
        bFeedMotor := FALSE;
        bClampOpen := FALSE;
        IF bGuardClosed AND bPartPresent AND bStartCycle THEN
            nState := 1;
        END_IF;

    1: (* Clamp close *)
        bClampMotor := TRUE;
        tClamp(TRUE, CLAMP_BUILD_MS);
        IF tClamp.Q THEN
            bClampMotor := FALSE;
            nState := 2;
        END_IF;

    2: (* Dri
no violations

Here the base model had the entire rule book in its system prompt and still could not stop naming timer parameters. It dropped the POU wrapper and avoided TIME literals - prompting bought that much - but wrote tClampPressure(IN := TRUE); and five more calls in the same style, six flagged named-parameter tokens in total, which is the single rule it broke: no_named_param, 95.59, 3392 tokens. It never gave those timers a period either, so the 1.5 s, 3 s and 0.8 s values from the request are simply absent, and it referenced btnReset in the fault-reset branch without ever declaring it. It also typed its step variable as an anonymous enumeration, eState : (IDLE, CLAMPING, DRILLING, DWELL, RETRACTING, OPEN_CLAMP);, which the scorer did not flag but the dialect does not support. The fine-tune needed 809 tokens for 100.0: CLAMP_BUILD_MS : INT := 1500 and DWELL_MS : INT := 3000 as constants, tClamp(TRUE, CLAMP_BUILD_MS) and tDwell(TRUE, DWELL_MS) positional, a numeric CASE nState OF step chain, and an E-stop branch at the top that drops every output and RETURNs before the sequencer can run. The step logic still deserves review - the dwell is nested inside the down-limit branch and the retract step drives the feed motor in the same direction as the drill step - but nothing in it violates the dialect.

Red spans in the base panel are the exact substrings the rule checker matched. The fine-tune panel is shown unmarked because it broke no rule on this prompt — read it against the base, line for line.

8

Every test prompt and its result

All 15 prompts, realistic condition (no rules in the system prompt). Prompt text is in the appendix below.

PromptBaseR24ChrFtok Fine-tuneR24ChrFtokRules the base broke
analog_scale_raw_to_bar
analog_math
violation91.1832.827197clean100.042.58995no_cast_function, no_pou_fb, no_select_fn
setpoint_clamp_and_ramp
analog_math
violation97.0627.556389clean100.051.15524no_pou_program
heater_power_avg_squared
analog_math
no code100.00.08192clean100.045.36520
timer_start_delay
timer_time_based
violation91.1828.218060clean100.041.15673no_VAR_GLOBAL, no_c_comment, no_pou_program
timer_blink_oscillator
timer_time_based
violation98.5322.081739clean100.030.25793no_double_quote
timer_offdelay_coastdown
timer_time_based
clean100.05.658192clean100.041.73460
io_terminal_seal_in
io_misc
no code100.00.08192clean100.046.95545
status_word_bit_mask
io_misc
no code100.00.08192clean100.049.83509
first_out_fault_annunciator
io_misc
violation94.1234.86621no code100.00.08192no_VAR_GLOBAL, no_pou_program
machine_cycle_full_program
program_structure
violation92.6530.935122clean100.046.5989no_named_param, no_pou_program
reusable_pump_block
program_structure
violation88.2428.568192clean100.041.881139no_TIME_literal, no_named_param, no_pou_fb
analog_scale_helper
program_structure
violation97.0630.246952clean100.023.58907no_pou_fb
retain_counter_power_cycle
data_memory
violation85.2930.491304clean100.033.4836no_STRING_type, no_VAR_GLOBAL, no_c_comment, no_single_quote, no_var_retain
shared_project_value
data_memory
violation94.1239.825166clean100.038.49450no_VAR_GLOBAL, no_pou_fb
machine_data_grouping
data_memory
violation86.7635.645671clean100.040.42876no_VAR_GLOBAL, no_double_quote, no_lowercase_kw, no_single_quote
9

Q4, Q6 and Q8 — what actually differs

Quantisation compresses the weights: Q8_0 keeps roughly 8 bits per weight, Q6_K about 6, Q4_K_M about 4. Smaller file, less VRAM, slightly noisier arithmetic. All sizes here are GiB as reported by the build tooling: 9.11 GiB (Q8_0), 7.04 GiB (Q6_K), 5.38 GiB (Q4_K_M). A note on the filenames: the "-tc" in qwen35_9b-tc.*.gguf is a build-pipeline tag left over from the tool-call training stage. It does not mark a tool-calling build. These files are published and supported as chat-only, as described above. On the code task the quant spread is small. An earlier internal 2000-prompt evaluation of these same fine-tunes measured code composite 0.836 (Q8_0), 0.832 (Q6_K) and 0.828 (Q4_K_M), with ChrF 47.4 / 46.0 / 44.5 - roughly 0.008 of composite across the whole range, far less than the difference between prompting styles. That evaluation's code holdout overlapped the training distribution by about 84%, so those absolute code scores are inflated and weakly discriminative between models. They are quoted here only to compare quantisations within this one model, and must not be used to rank the four models against each other. The large Q4 penalty reported earlier for this family was measured on the tool-calling task, where low precision damaged structured argument emission (0.816 at Q8_0 down to 0.642 at Q4_K_M). Tool calling has been dropped from this line as a product decision, so for a chat-only deployment the quantisation choice is a memory decision, not an accuracy decision. Methodology, stated the same way in all four cards. The R24 and clean figures here are computed over the whole reply. Base replies were prose-wrapped 66.7-100% of the time and fine-tune replies 0-20%, so whole-reply scoring flatters the fine-tunes; scoring only the fenced code moves the pooled base clean rate from 6.7% to 10.0%. For this model's own base arm the two variants agree exactly (6.7% minimal, 26.7% full under both), and the base-to-fine-tune gap is not sensitive to the choice - but the variant is named for the record. Reproducibility. The head-to-head ran Ollama tags stcoder_qwen35-9b:q8 against base qwen3.5:9b; the siblings were stcoder_qwen25-7b:q8 / qwen2.5-coder:7b, stcoder_qwen25-14b:q6 / qwen2.5-coder:14b, and stcoder_gemma4-12b:q6 / gemma4:12b. The run data records no quantisation at all for the base arms, so "effectively Q4" is an inference from the Ollama library default, not a recorded value. The comparison is therefore not precision-matched.

Evidence base — and its limit

From an earlier 2,000-prompt internal evaluation of these same fine-tunes: code composite Q8 0.836 / Q6 0.832 / Q4 0.828; ChrF 47.4 / 46.0 / 44.5. The head-to-head study on this page used the Q8_0 build. File sizes are GiB.

Important caveat. That earlier evaluation's code holdout overlapped the training distribution by roughly 84%, so its absolute code scores are inflated and only weakly separate one model from another. Use those figures for the within-model quantisation comparison, which is what they are cited for here — not to rank the four models against each other. For cross-model ranking use the identical-conditions table in section 2.

10

Strengths, weaknesses, and what not to use it for

Strengths

Weaknesses

Not the right tool for

11

How the numbers were produced

240 controlled generations across four model families, 15 prompts each, run twice per model under two prompting conditions. Greedy decoding (temperature 0, seed 42), 8,192-token budget, local Ollama on an RTX 5080 (16 GB).

Limitations

  • Quantisation was not matched. The fine-tune ran at higher precision than the stock base build. This plausibly helps the fine-tune on quality and demonstrably penalises it on raw throughput.
  • “Clean” is not “correct”. R24 is reference-free: it certifies that no forbidden construct appears, not that the logic works. Correctness was checked by reading the code, not by compiling it.
  • The published builds do not ship this decoding configuration. These numbers were measured greedily (temperature 0, seed 42) at num_ctx 16384 / num_predict 8192. The Modelfiles published with the models default to temperature 0.2 and num_ctx 8192 — the sequence length the models were trained at — because that is the better interactive default. Match the three measured values to reproduce these figures; otherwise expect run-to-run variation.
  • Scope. 15 prompts, one dialect, English only, no tool-calling, and the prompt set was deliberately built on the categories where this dialect diverges from standard IEC. It measures dialect fidelity on dialect-sensitive work, not general PLC competence.
  • Small sample. 15 prompts per condition per model. Treat single-prompt differences as anecdote and the aggregate as the signal.
12

In summary

stcoder-qwen35-9b removes the dialect problem for this base: R24 100.0 with an empty violation table on every delivered reply, no POU wrappers, no TIME literals, no named function-block parameters, and code that lands close to the hand-written reference (ChrF 40.9 over its 14 delivered minimal-prompt replies, 38.2 over all 15). It also cut its base's runaway reasoning from 6345 tokens to 1227 and its answer time from 54.5 s to 17.2 s. What it does not do is guarantee an answer - one reply in fifteen reasoned until the budget ran out and returned nothing - nor guarantee working logic, since three clean replies in this study contained real defects and doubled comment delimiters appeared in 2 of 15 minimal and 6 of 15 full-condition replies. It is chat-only: tool calling was trained, measured, and then dropped from this line as a product decision. If you have not already chosen, start with the line default, stcoder-qwen25-7b at Q6_K, and come here for the reasoning family. Either way, keep the context at 8192 or more, treat the output as a fast first draft in the right dialect, and compile everything in LogicStudio before it goes near a machine.

A

Appendix — the test prompts

IDPrompt as sent to the model
analog_scale_raw_to_barWe have a 4-20 mA pressure transmitter landing on an analog input card. The raw word comes in as an integer where 5530 counts = 4 mA and 27648 counts = 20 mA, and the transmitter is ranged 0-16 bar. Can you write the Structured Text that turns the raw count into bar, keeps the result inside 0 to 16, and sets a wire-break flag when the loop current drops under about 3.6 mA (roughly 4977 counts)? There is also an enable bit coming from the scan logic - when that is off the scaled output should read zero.
setpoint_clamp_and_rampOperators type a hot water setpoint on the HMI. Anything below 40.0 degC or above 85.0 degC has to be pulled back to the nearest limit and an out-of-range warning bit raised, which the operator can acknowledge once the entry is sensible again. On top of that, the setpoint that actually goes to the controller must not jump more than 2.0 degC per PLC cycle, so ramp it toward the clamped target. Structured Text please.
heater_power_avg_squaredHeater panel job. I sample the load current every scan and it is noisy, so I need the average of the last 8 samples first. From that averaged current I want the dissipated power using P = I squared times R, with R = 4.7 ohm. If the power stays above 1500 W for 3 seconds, latch an overload bit that only clears when the operator hits the reset pushbutton. Please write it in Structured Text.
timer_start_delayWe've got a conveyor with a pre-start warning horn. When the operator hits the start button I need the horn to sound for 5 seconds first, then the motor kicks in and the horn goes quiet. Pressing stop, or any fault coming in, has to drop the motor and the horn straight away. Can you write the Structured Text for this?
timer_blink_oscillatorNeed a flashing warning beacon done in ST. While the machine warning flag is active the lamp should blink continuously - lit for 500 ms, then dark for 1.5 seconds, over and over. When the warning clears the lamp stays off. There's no hardware flasher relay on this panel so it has to be done in software.
timer_offdelay_coastdownOur extraction fan needs a run-on timer. The fan should run whenever the main motor is running, and then keep running for 30 seconds after the motor is switched off so the duct clears. Emergency stop has to cut the motor and the fan immediately, with no run-on. Also give me a flag I can put on the HMI to show that the purge is in progress. Structured Text please.
io_terminal_seal_inOn a small booster pump skid the start pushbutton is wired to the first digital input on the CPU and the pump contactor sits on the first relay output. There is an NC stop button on the second input and the motor thermal overload aux contact on the third. Write me the ST for a normal start/stop seal-in: the start button is bouncy so it has to be held for half a second before the pump picks up, an overload trip must latch the pump out, and the lockout should only clear when the operator presses stop with the overload already reset.
status_word_bit_maskWe poll a 16-bit status word from a VFD over Modbus TCP. The drive manual gives the bit masks in hex: 16#0001 = ready, 16#0002 = running, 16#0008 = fault, 16#0080 = at speed, and 16#0F00 holds a 4-bit warning code. Break the word out into individual booleans, pull the warning code out as a plain integer 0-15, and give me a single 'drive healthy' flag that is only true when it is ready and running with no fault and no warning. If the Modbus poll is not valid, do not trust any of it.
first_out_fault_annunciatorI need a first-out fault annunciator for a compressor package. The trip inputs are low oil pressure, high discharge temperature, motor overload and high vibration. Whichever one trips first has to be captured and reported to the HMI so the operator can tell at a glance which fault actually caused the trip - later trips must not overwrite it. I also want a common alarm lamp, a horn that goes quiet when acknowledge is pressed while the lamp stays on, and a reset that is only accepted when all four inputs are clear again. The compressor run permissive drops out on any fault.
machine_cycle_full_programCan you write the complete program for our drilling station cycle? One part per cycle: when the guard is closed, a part is present and there are no alarms, pressing Start closes the clamp, waits 1.5 s for clamp pressure to build, then the drill motor starts and the head feeds down until the down limit switch, dwells 3 s at depth, retracts until the up limit, opens the clamp after 0.8 s, and the station goes back to idle ready for the next part while incrementing the part counter. E-stop or drill overload must drop every output and put the station into a fault state that only the reset button clears. I want the step sequencing and the timers in there.
reusable_pump_blockWe have three identical booster pumps on a skid and I am tired of copy-pasting the same logic three times. Give me one reusable pump control block that I can instantiate once per pump. Per instance: run request, motor thermal overload contact, dry-run (low suction) switch, fault reset. Outputs: motor run command, fault flag, and a fault code for the HMI so the operator can see what tripped. Behaviour: 2 s start delay after the run request, the dry-run switch is only evaluated 5 s after the motor has actually started, overload trips immediately, any trip latches until it is reset, and once the pump stops it has to stay off for at least 10 s before it may restart. Also show me how it gets called for the three pumps.
analog_scale_helperI need a small reusable routine that turns the raw analog word from our pressure transmitters into engineering units, because I have to do the same conversion for three transmitters on the line. They are 4-20 mA into a 0-32767 count input card, so 6553 counts is 4 mA and 32767 counts is 20 mA, and the measuring range is 0-250 bar. It should clamp the result to the range and set an out-of-range flag, plus a broken-wire flag when the raw count drops below 3200 (under 2 mA). A rounded integer in tenths of a bar for the HMI would be handy too.
retain_counter_power_cycleLine 3 bottler: a photo eye (bPartDetect) pulses once for every bottle that goes past. I need two totals out of it - a lifetime total that goes into the maintenance records, and a shift total that the supervisor clears with bShiftReset at the start of each shift. The problem is we power the cabinet down every night and in the morning both totals come back as zero, so quality has no numbers to report. Write the ST for the counting logic so the lifetime total is still correct after the panel comes back up.
shared_project_valueTank farm project. The level control block works out the tank level in percent and a plant-running flag. The alarm block, the Modbus reporting block and the HMI block all need to see exactly the same two values on every scan - right now each of them recalculates them on its own and they end up disagreeing with each other. Write the level control block so those two values are produced in one place and the rest of the project can use them. Inputs are rLevelRaw from the transmitter (already scaled 0-100), bPumpRunning, bValveOpen and bEStop; the published percent should stay inside 0-100 and a transmitter reading outside range has to be flagged.
machine_data_groupingI have three identical packaging machines in one project. For each machine I track a temperature setpoint, the actual temperature, the belt speed, a cycle counter and a state code. I would like each machine's values kept together so I can run the same monitoring logic over all three in a loop instead of copy-pasting the same block three times. Logic per machine: if it is in the running state and the actual temperature is more than 5 degrees off setpoint, or the belt speed drops below 10, fault it; count a cycle on each rising edge of that machine's cycle-done input; EStop faults all of them. Also give me how many machines are currently faulted. Write it in ST.